Nettoyage post-revue : factorisation, code mort et fiabilité (reorder/lore/foundry/PDF)
All checks were successful
All checks were successful
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:
17
core/pom.xml
17
core/pom.xml
@@ -148,6 +148,23 @@
|
||||
<artifactId>tink</artifactId>
|
||||
<version>1.21.0</version>
|
||||
</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>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Arc;
|
||||
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.ChapterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||
@@ -117,4 +118,16 @@ public class ArcService {
|
||||
public boolean arcExists(String 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
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.SceneRepository;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
@@ -97,4 +98,19 @@ public class ChapterService {
|
||||
public boolean chapterExists(String 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Enemy;
|
||||
import com.loremind.domain.shared.ReorderSupport;
|
||||
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
|
||||
import com.loremind.domain.images.Image;
|
||||
import com.loremind.application.images.ImageService;
|
||||
@@ -91,6 +92,19 @@ public class EnemyService {
|
||||
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 -------------------
|
||||
|
||||
/**
|
||||
@@ -130,9 +144,12 @@ public class EnemyService {
|
||||
String ext = contentType.contains("png") ? "png"
|
||||
: contentType.contains("jpeg") || contentType.contains("jpg") ? "jpg"
|
||||
: 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;
|
||||
try {
|
||||
Image img = imageService.upload(filename, contentType,
|
||||
Image img = imageService.upload(filename, mimeType,
|
||||
new ByteArrayInputStream(bytes), bytes.length);
|
||||
return img.getId();
|
||||
} catch (RuntimeException e) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Campaign;
|
||||
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.NpcRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -102,6 +103,19 @@ public class NpcService {
|
||||
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) {
|
||||
if (query == null || query.isBlank()) return List.of();
|
||||
return npcRepository.searchByName(query.trim());
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Campaign;
|
||||
import com.loremind.domain.campaigncontext.RandomTable;
|
||||
import com.loremind.domain.shared.ReorderSupport;
|
||||
import com.loremind.domain.campaigncontext.RandomTableEntry;
|
||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.RandomTableGenerator;
|
||||
@@ -85,6 +86,15 @@ public class RandomTableService {
|
||||
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) {
|
||||
if (query == null || query.isBlank()) return List.of();
|
||||
return repository.searchByName(query.trim());
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Scene;
|
||||
import com.loremind.domain.shared.ReorderSupport;
|
||||
import com.loremind.domain.campaigncontext.SceneBranch;
|
||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -92,6 +94,25 @@ public class SceneService {
|
||||
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 :
|
||||
* 1. Pas d'auto-référence (scène qui pointe sur elle-même).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.loremind.application.lorecontext;
|
||||
|
||||
import com.loremind.domain.lorecontext.LoreNode;
|
||||
import com.loremind.domain.shared.ReorderSupport;
|
||||
import com.loremind.domain.lorecontext.Page;
|
||||
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
|
||||
import com.loremind.domain.lorecontext.ports.PageRepository;
|
||||
@@ -45,10 +46,20 @@ public class LoreNodeService {
|
||||
.icon(changes.getIcon())
|
||||
.parentId(changes.getParentId())
|
||||
.loreId(changes.getLoreId())
|
||||
.order(nextOrderFor(changes.getLoreId(), changes.getParentId()))
|
||||
.build();
|
||||
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) {
|
||||
return loreNodeRepository.findById(id);
|
||||
}
|
||||
@@ -57,6 +68,37 @@ public class LoreNodeService {
|
||||
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) {
|
||||
return loreNodeRepository.findByLoreId(loreId);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.loremind.domain.lorecontext.ports.PageRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.loremind.domain.shared.CollectionUtils;
|
||||
import com.loremind.domain.shared.ReorderSupport;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
@@ -33,6 +34,7 @@ public class PageService {
|
||||
.nodeId(nodeId)
|
||||
.templateId(templateId)
|
||||
.title(title)
|
||||
.order(nextOrderFor(nodeId))
|
||||
.values(new HashMap<>())
|
||||
.tags(new ArrayList<>())
|
||||
.relatedPageIds(new ArrayList<>())
|
||||
@@ -40,6 +42,14 @@ public class PageService {
|
||||
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) {
|
||||
return pageRepository.findById(id);
|
||||
}
|
||||
@@ -87,4 +97,19 @@ public class PageService {
|
||||
public void deletePage(String 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ public class LoreNode {
|
||||
private String icon;
|
||||
private String parentId; // Auto-référence pour l'arborescence
|
||||
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 updatedAt;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ public class Page {
|
||||
private String templateId;
|
||||
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. */
|
||||
private Map<String, String> values;
|
||||
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,10 @@ public class LoreNodeJpaEntity {
|
||||
@Column(name = "lore_id", nullable = false)
|
||||
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)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
|
||||
@@ -45,6 +45,10 @@ public class PageJpaEntity {
|
||||
@Column(nullable = false)
|
||||
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")
|
||||
@Convert(converter = StringMapJsonConverter.class)
|
||||
private Map<String, String> values;
|
||||
|
||||
@@ -95,6 +95,7 @@ public class PostgresLoreNodeRepository implements LoreNodeRepository {
|
||||
.icon(jpaEntity.getIcon())
|
||||
.parentId(jpaEntity.getParentId() != null ? jpaEntity.getParentId().toString() : null)
|
||||
.loreId(jpaEntity.getLoreId().toString())
|
||||
.order(jpaEntity.getOrder())
|
||||
.createdAt(jpaEntity.getCreatedAt())
|
||||
.updatedAt(jpaEntity.getUpdatedAt())
|
||||
.build();
|
||||
@@ -111,6 +112,7 @@ public class PostgresLoreNodeRepository implements LoreNodeRepository {
|
||||
.icon(loreNode.getIcon())
|
||||
.parentId(parentId)
|
||||
.loreId(Long.parseLong(loreNode.getLoreId()))
|
||||
.order(loreNode.getOrder())
|
||||
.createdAt(loreNode.getCreatedAt())
|
||||
.updatedAt(loreNode.getUpdatedAt())
|
||||
.build();
|
||||
|
||||
@@ -91,6 +91,7 @@ public class PostgresPageRepository implements PageRepository {
|
||||
.nodeId(e.getNodeId() != null ? e.getNodeId().toString() : null)
|
||||
.templateId(e.getTemplateId() != null ? e.getTemplateId().toString() : null)
|
||||
.title(e.getTitle())
|
||||
.order(e.getOrder())
|
||||
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
|
||||
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : 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()
|
||||
? Long.parseLong(p.getTemplateId()) : null)
|
||||
.title(p.getTitle())
|
||||
.order(p.getOrder())
|
||||
.values(p.getValues() != null ? new HashMap<>(p.getValues()) : new HashMap<>())
|
||||
.imageValues(p.getImageValues() != null ? new HashMap<>(p.getImageValues()) : new HashMap<>())
|
||||
.keyValueValues(p.getKeyValueValues() != null ? new HashMap<>(p.getKeyValueValues()) : new HashMap<>())
|
||||
|
||||
@@ -99,9 +99,10 @@ public class FoundryExportService {
|
||||
CampaignJpaEntity campaign = campaignRepo.findById(Long.parseLong(campaignId))
|
||||
.orElseThrow(() -> new NoSuchElementException("Campagne introuvable : " + campaignId));
|
||||
|
||||
List<TemplateField> npcTemplate = resolveTemplate(campaign.getGameSystemId(), true);
|
||||
List<TemplateField> enemyTemplate = resolveTemplate(campaign.getGameSystemId(), false);
|
||||
String foundryActorType = resolveActorType(campaign.getGameSystemId());
|
||||
GameSystemJpaEntity gameSystem = resolveGameSystem(campaign.getGameSystemId());
|
||||
List<TemplateField> npcTemplate = gameSystem != null ? gameSystem.getNpcTemplate() : null;
|
||||
List<TemplateField> enemyTemplate = gameSystem != null ? gameSystem.getEnemyTemplate() : null;
|
||||
String foundryActorType = gameSystem != null ? gameSystem.getFoundryActorType() : null;
|
||||
|
||||
AssetRegistry assets = new AssetRegistry();
|
||||
|
||||
@@ -275,23 +276,11 @@ public class FoundryExportService {
|
||||
|
||||
// ----- 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;
|
||||
try {
|
||||
return gameSystemRepo.findById(Long.parseLong(gameSystemId))
|
||||
.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);
|
||||
return gameSystemRepo.findById(Long.parseLong(gameSystemId)).orElse(null);
|
||||
} catch (NumberFormatException ex) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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 & ennemis (bestiaire), lore, et battlemaps en illustration.
|
||||
* <p>
|
||||
* Rendu XHTML+CSS -> 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("&");
|
||||
case '<' -> b.append("<");
|
||||
case '>' -> b.append(">");
|
||||
case '"' -> b.append(""");
|
||||
default -> b.append(c);
|
||||
}
|
||||
}
|
||||
return b.toString();
|
||||
}
|
||||
|
||||
/** Comme esc, mais les sauts de ligne deviennent des <br/>. */
|
||||
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; }
|
||||
""";
|
||||
}
|
||||
@@ -70,4 +70,13 @@ public class ArcController {
|
||||
}
|
||||
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) {}
|
||||
}
|
||||
|
||||
@@ -89,4 +89,13 @@ public class ChapterController {
|
||||
}
|
||||
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) {}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,15 @@ public class EnemyController {
|
||||
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) {
|
||||
return new EnemyService.EnemyData(
|
||||
req.name(), req.level(), req.folder(),
|
||||
|
||||
@@ -109,4 +109,13 @@ public class LoreNodeController {
|
||||
}
|
||||
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) {}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,15 @@ public class NpcController {
|
||||
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) {
|
||||
return new NpcService.NpcData(
|
||||
dto.getName(),
|
||||
|
||||
@@ -86,4 +86,13 @@ public class PageController {
|
||||
pageService.deletePage(id);
|
||||
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) {}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 ImproviseRequest(String campaignId, String tableName, String resultLabel, String resultDetail) {}
|
||||
|
||||
@@ -62,4 +62,13 @@ public class SceneController {
|
||||
sceneService.deleteScene(id);
|
||||
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) {}
|
||||
}
|
||||
|
||||
@@ -14,4 +14,5 @@ public class LoreNodeDTO {
|
||||
private String icon;
|
||||
private String parentId;
|
||||
private String loreId;
|
||||
private int order;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ public class PageDTO {
|
||||
private String nodeId;
|
||||
private String templateId;
|
||||
private String title;
|
||||
private int order;
|
||||
private Map<String, String> values;
|
||||
/** Pour chaque champ IMAGE du template, la liste ordonnee des IDs d'images. */
|
||||
private Map<String, List<String>> imageValues;
|
||||
|
||||
@@ -21,6 +21,7 @@ public class LoreNodeMapper {
|
||||
dto.setIcon(loreNode.getIcon());
|
||||
dto.setParentId(loreNode.getParentId());
|
||||
dto.setLoreId(loreNode.getLoreId());
|
||||
dto.setOrder(loreNode.getOrder());
|
||||
return dto;
|
||||
}
|
||||
|
||||
@@ -35,6 +36,7 @@ public class LoreNodeMapper {
|
||||
.icon(dto.getIcon())
|
||||
.parentId(dto.getParentId())
|
||||
.loreId(dto.getLoreId())
|
||||
.order(dto.getOrder())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ public class PageMapper {
|
||||
dto.setNodeId(page.getNodeId());
|
||||
dto.setTemplateId(page.getTemplateId());
|
||||
dto.setTitle(page.getTitle());
|
||||
dto.setOrder(page.getOrder());
|
||||
dto.setValues(CollectionUtils.copyMap(page.getValues()));
|
||||
dto.setImageValues(CollectionUtils.copyMap(page.getImageValues()));
|
||||
dto.setKeyValueValues(CollectionUtils.copyMap(page.getKeyValueValues()));
|
||||
@@ -41,6 +42,7 @@ public class PageMapper {
|
||||
.nodeId(dto.getNodeId())
|
||||
.templateId(dto.getTemplateId())
|
||||
.title(dto.getTitle())
|
||||
.order(dto.getOrder())
|
||||
.values(CollectionUtils.copyMap(dto.getValues()))
|
||||
.imageValues(CollectionUtils.copyMap(dto.getImageValues()))
|
||||
.keyValueValues(CollectionUtils.copyMap(dto.getKeyValueValues()))
|
||||
|
||||
9
core/src/main/resources/db/migration/V6__lore_order.sql
Normal file
9
core/src/main/resources/db/migration/V6__lore_order.sql
Normal 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;
|
||||
@@ -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
19
web/package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "1.0.0-beta",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^21.2.16",
|
||||
"@angular/cdk": "^21.2.14",
|
||||
"@angular/common": "^21.2.16",
|
||||
"@angular/compiler": "^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": {
|
||||
"version": "21.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.14.tgz",
|
||||
@@ -11201,7 +11218,6 @@
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^8.0.0"
|
||||
@@ -11255,7 +11271,6 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^21.2.16",
|
||||
"@angular/cdk": "^21.2.14",
|
||||
"@angular/common": "^21.2.16",
|
||||
"@angular/compiler": "^21.2.16",
|
||||
"@angular/core": "^21.2.16",
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
[titleRoute]="config.titleRoute || null"
|
||||
[items]="config.items"
|
||||
[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>
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule, BookOpen } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
@@ -40,7 +39,6 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
@@ -64,7 +62,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
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 }) => {
|
||||
this.existingArcCount = treeData.arcs.length;
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import { switchMap } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
@@ -77,7 +76,6 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
@@ -121,7 +119,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
arc: this.campaignService.getArcById(this.arcId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
|
||||
@@ -40,10 +40,12 @@
|
||||
</p>
|
||||
}
|
||||
@if (hubQuests.length > 0) {
|
||||
<div class="hub-quest-grid">
|
||||
@for (q of hubQuests; track q) {
|
||||
<div class="hub-quest-grid" cdkDropList cdkDropListOrientation="mixed"
|
||||
(cdkDropListDropped)="dropQuest($event)">
|
||||
@for (q of hubQuests; track q.id) {
|
||||
<button type="button"
|
||||
class="hub-quest-card"
|
||||
cdkDrag [cdkDragData]="q"
|
||||
(click)="openQuest(q)">
|
||||
<div class="hub-quest-card-head">
|
||||
@if (q.icon) {
|
||||
|
||||
@@ -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 { forkJoin, of } from 'rxjs';
|
||||
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 { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { resolveCampaignIcon } from '../../campaign-icons';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
@@ -27,7 +29,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-arc-view',
|
||||
imports: [RouterModule, LucideAngularModule, ImageGalleryComponent, TranslatePipe],
|
||||
imports: [RouterModule, LucideAngularModule, ImageGalleryComponent, TranslatePipe, CdkDropList, CdkDrag],
|
||||
templateUrl: './arc-view.component.html',
|
||||
styleUrls: ['./arc-view.component.scss']
|
||||
})
|
||||
@@ -59,7 +61,6 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
@@ -67,11 +68,16 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private translate: TranslateService
|
||||
private translate: TranslateService,
|
||||
private dataSync: DataSyncService,
|
||||
private destroyRef: DestroyRef
|
||||
) {}
|
||||
|
||||
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 newArcId = pm.get('arcId')!;
|
||||
if (newArcId !== this.arcId || newCampaignId !== this.campaignId) {
|
||||
@@ -87,7 +93,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
arc: this.campaignService.getArcById(this.arcId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
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 {
|
||||
if (!q.id) return;
|
||||
this.router.navigate([
|
||||
|
||||
@@ -9,7 +9,7 @@ const t = { instant: (k: string) => k } as unknown as TranslateService;
|
||||
function data(partial: Partial<CampaignTreeData> = {}): CampaignTreeData {
|
||||
return {
|
||||
arcs: [], chaptersByArc: {}, scenesByChapter: {},
|
||||
characters: [], npcs: [], randomTables: [], enemies: [],
|
||||
npcs: [], randomTables: [], enemies: [],
|
||||
...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 = [
|
||||
{ id: 'a', name: '10. Final' },
|
||||
{ id: 'b', name: '2. Voyage' },
|
||||
{ id: 'c', name: '1. Intro' },
|
||||
{ id: 'a', name: 'Final', order: 2 },
|
||||
{ id: 'b', name: 'Voyage', order: 1 },
|
||||
{ id: 'c', name: 'Intro', order: 0 },
|
||||
];
|
||||
const tree = buildCampaignTree('c1', data({ arcs: arcs as never }), t);
|
||||
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', () => {
|
||||
@@ -47,7 +47,7 @@ describe('buildCampaignTree', () => {
|
||||
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 = [
|
||||
{ id: 'n1', name: 'Alice', folder: 'Ville' },
|
||||
{ id: 'n2', name: 'Bob', folder: 'Ville' },
|
||||
@@ -58,15 +58,19 @@ describe('buildCampaignTree', () => {
|
||||
expect(npcsRoot.meta).toBe('3');
|
||||
const folder = npcsRoot.children!.find((c) => c.id === 'npc-folder-Ville')!;
|
||||
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", () => {
|
||||
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);
|
||||
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 »)', () => {
|
||||
|
||||
@@ -2,13 +2,12 @@ import { Observable, forkJoin, of } from 'rxjs';
|
||||
import { switchMap, map } from 'rxjs/operators';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { CampaignService } from '../services/campaign.service';
|
||||
import { CharacterService } from '../services/character.service';
|
||||
import { NpcService } from '../services/npc.service';
|
||||
import { RandomTableService } from '../services/random-table.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 { Character } from '../services/character.model';
|
||||
import { Npc } from '../services/npc.model';
|
||||
import { RandomTable } from '../services/random-table.model';
|
||||
import { Enemy } from '../services/enemy.model';
|
||||
@@ -26,7 +25,6 @@ export interface CampaignTreeData {
|
||||
arcs: Arc[];
|
||||
chaptersByArc: Record<string, Chapter[]>;
|
||||
scenesByChapter: Record<string, Scene[]>;
|
||||
characters: Character[];
|
||||
npcs: Npc[];
|
||||
randomTables: RandomTable[];
|
||||
enemies: Enemy[];
|
||||
@@ -35,7 +33,6 @@ export interface CampaignTreeData {
|
||||
export function loadCampaignTreeData(
|
||||
service: CampaignService,
|
||||
campaignId: string,
|
||||
characterService: CharacterService,
|
||||
npcService: NpcService,
|
||||
// Optionnel pour ne pas casser les ~15 appelants existants : si fourni, les
|
||||
// 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).
|
||||
return forkJoin({
|
||||
arcs: service.getArcs(campaignId),
|
||||
characters: of([] as Character[]),
|
||||
npcs: npcService.getByCampaign(campaignId),
|
||||
randomTables: randomTableService
|
||||
? randomTableService.getByCampaign(campaignId).pipe(catchError(() => of([] as RandomTable[])))
|
||||
@@ -58,9 +54,9 @@ export function loadCampaignTreeData(
|
||||
? enemyService.getByCampaign(campaignId).pipe(catchError(() => of([] as Enemy[])))
|
||||
: of([] as Enemy[])
|
||||
}).pipe(
|
||||
switchMap(({ arcs, characters, npcs, randomTables, enemies }) => {
|
||||
switchMap(({ arcs, npcs, randomTables, enemies }) => {
|
||||
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 =>
|
||||
service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters })))
|
||||
@@ -75,7 +71,7 @@ export function loadCampaignTreeData(
|
||||
});
|
||||
|
||||
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 =>
|
||||
service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes })))
|
||||
@@ -84,7 +80,7 @@ export function loadCampaignTreeData(
|
||||
map(sceneResults => {
|
||||
const scenesByChapter: Record<string, Scene[]> = {};
|
||||
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[] {
|
||||
// Tri FR avec `numeric: true` pour que "1. Intro", "2. Voyage", "10. Final" soient
|
||||
// classés 1, 2, 10 (et pas 1, 10, 2). `sensitivity: 'base'` ignore la casse.
|
||||
const byName = (a: { name: string }, b: { name: string }) =>
|
||||
a.name.localeCompare(b.name, 'fr', { numeric: true, sensitivity: 'base' });
|
||||
// Tri par ORDRE manuel (réagencé par glisser-déposer, persisté en base ; `byOrder`
|
||||
// et `groupByFolder` sont mutualisés avec les vues cartes). Repli sur 0 sinon.
|
||||
|
||||
/**
|
||||
* 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
|
||||
// (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
|
||||
// à 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".
|
||||
const sortedNpcs = [...data.npcs].sort(byName);
|
||||
const sortedNpcs = [...data.npcs].sort(byOrder);
|
||||
const npcItem = (n: Npc): TreeItem => ({
|
||||
id: `npc-${n.id}`,
|
||||
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
|
||||
// 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 npcChildren = folderChildren(sortedNpcs, 'npc', npcItem, 'npc');
|
||||
|
||||
const npcsNode: TreeItem = {
|
||||
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
|
||||
// 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 => ({
|
||||
id: `enemy-${e.id}`,
|
||||
label: e.name,
|
||||
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 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 enemyChildren = folderChildren(sortedEnemies, 'enemy', enemyItem, 'enemy');
|
||||
|
||||
const sortedArcs = [...data.arcs].sort(byName);
|
||||
const sortedArcs = [...data.arcs].sort(byOrder);
|
||||
|
||||
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 sortedScenes = [...(data.scenesByChapter[ch.id!] ?? [])].sort(byName);
|
||||
const sortedScenes = [...(data.scenesByChapter[ch.id!] ?? [])].sort(byOrder);
|
||||
|
||||
const sceneItems: TreeItem[] = sortedScenes.map(sc => ({
|
||||
id: `scene-${sc.id}`,
|
||||
label: sc.name,
|
||||
iconKey: sc.icon ?? undefined,
|
||||
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}/scenes/${sc.id}`
|
||||
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}/scenes/${sc.id}`,
|
||||
dragKind: 'scene', dragId: sc.id
|
||||
}));
|
||||
return {
|
||||
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).
|
||||
meta: (ch.prerequisites?.length ?? 0) > 0 ? '🔒' : undefined,
|
||||
children: sceneItems,
|
||||
dragKind: 'chapter', dragId: ch.id, dropKinds: ['scene'], dropParentId: ch.id,
|
||||
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}`,
|
||||
createActions: [{
|
||||
id: `new-scene-${ch.id}`,
|
||||
@@ -228,6 +195,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData, tr
|
||||
label: arc.name,
|
||||
iconKey: arc.icon ?? undefined,
|
||||
children: chapterItems,
|
||||
dragKind: 'arc', dragId: arc.id, dropKinds: ['chapter'], dropParentId: arc.id,
|
||||
route: `/campaigns/${campaignId}/arcs/${arc.id}`,
|
||||
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 => ({
|
||||
id: `random-table-${t.id}`,
|
||||
label: t.name,
|
||||
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 = {
|
||||
@@ -254,6 +223,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData, tr
|
||||
label: translate.instant('campaignTree.randomTables'),
|
||||
iconKey: 'dice',
|
||||
children: tableItems,
|
||||
dropKinds: ['table'], dropParentId: '',
|
||||
meta: tableItems.length ? String(tableItems.length) : undefined,
|
||||
sectionHeaderBefore: translate.instant('campaignTree.sectionTools'),
|
||||
createActions: [{
|
||||
@@ -340,6 +310,10 @@ export function buildCampaignSidebarConfig(
|
||||
],
|
||||
globalItems,
|
||||
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 }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<lucide-icon [img]="Download" [size]="14"></lucide-icon>
|
||||
{{ (exportingFoundry ? 'campaignDetail.foundryExporting' : 'campaignDetail.foundryExport') | translate }}
|
||||
@@ -136,17 +140,24 @@
|
||||
</button>
|
||||
</div>
|
||||
@if (npcs.length > 0) {
|
||||
<div class="characters-grid">
|
||||
@for (npc of npcs; track npc) {
|
||||
<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>
|
||||
@for (g of npcGroups; track g.folder) {
|
||||
@if (g.folder) {
|
||||
<h4 class="persona-folder">{{ g.folder.split('/').join(' / ') }}</h4>
|
||||
} @else if (npcGroups.length > 1) {
|
||||
<h4 class="persona-folder persona-folder--none">{{ 'campaignTree.unclassified' | translate }}</h4>
|
||||
}
|
||||
</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) {
|
||||
<div class="empty-state empty-state--compact">
|
||||
@@ -172,9 +183,10 @@
|
||||
</div>
|
||||
</div>
|
||||
@if (arcs.length > 0) {
|
||||
<div class="arcs-grid">
|
||||
@for (arc of arcs; track arc) {
|
||||
<div class="arc-card" (click)="openArc(arc)">
|
||||
<div class="arcs-grid" cdkDropList cdkDropListOrientation="mixed"
|
||||
(cdkDropListDropped)="dropArc($event)">
|
||||
@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>
|
||||
<span class="arc-name">{{ arc.name }}</span>
|
||||
<span class="arc-meta">{{ 'campaignDetail.chapters' | translate:{ n: chapterCountByArc[arc.id!] || 0 } }}</span>
|
||||
|
||||
@@ -505,3 +505,15 @@
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -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 { 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 { Router, RouterLink } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
@@ -11,7 +15,6 @@ import { CampaignService } from '../../../services/campaign.service';
|
||||
import { LoreService } from '../../../services/lore.service';
|
||||
import { GameSystemService } from '../../../services/game-system.service';
|
||||
import { GameSystem } from '../../../services/game-system.model';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.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 { loadCampaignTreeData, buildCampaignSidebarConfig, CampaignTreeData } from '../../campaign-tree.helper';
|
||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||
import { FolderGroup, groupByFolder, byOrder } from '../../../shared/folder-grouping.util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-campaign-detail',
|
||||
imports: [FormsModule, LucideAngularModule, RouterLink, TranslatePipe],
|
||||
imports: [FormsModule, LucideAngularModule, RouterLink, TranslatePipe, CdkDropList, CdkDrag],
|
||||
templateUrl: './campaign-detail.component.html',
|
||||
styleUrls: ['./campaign-detail.component.scss']
|
||||
})
|
||||
@@ -46,9 +50,12 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
readonly Upload = Upload;
|
||||
readonly Sparkles = Sparkles;
|
||||
readonly Download = Download;
|
||||
readonly FileText = FileText;
|
||||
|
||||
/** Export Foundry en cours (anti double-clic). */
|
||||
exportingFoundry = false;
|
||||
/** Export PDF en cours (anti double-clic). */
|
||||
exportingPdf = false;
|
||||
|
||||
campaign: Campaign | null = null;
|
||||
arcs: Arc[] = [];
|
||||
@@ -64,6 +71,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
linkedGameSystem: GameSystem | null = null;
|
||||
/** Fiches de personnages non-joueurs (PNJ) de la campagne. */
|
||||
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: Session[] = [];
|
||||
/**
|
||||
@@ -98,7 +107,6 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
private campaignService: CampaignService,
|
||||
private loreService: LoreService,
|
||||
private gameSystemService: GameSystemService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
@@ -107,37 +115,32 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private translate: TranslateService
|
||||
private translate: TranslateService,
|
||||
private dataSync: DataSyncService,
|
||||
private campaignSidebar: CampaignSidebarService,
|
||||
private destroyRef: DestroyRef
|
||||
) {}
|
||||
|
||||
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
|
||||
// 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).
|
||||
this.route.paramMap.pipe(
|
||||
map(pm => pm.get('id')),
|
||||
filter((id): id is string => !!id && id !== this.campaign?.id),
|
||||
switchMap(id => forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(id),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
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);
|
||||
});
|
||||
switchMap(id => this.loadCampaignBundle(id)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe(result => this.applyCampaignBundle(result));
|
||||
}
|
||||
|
||||
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é.
|
||||
*/
|
||||
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),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
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))
|
||||
treeData: loadCampaignTreeData(this.campaignService, id, this.npcService, this.randomTableService, this.enemyService).pipe(
|
||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, 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);
|
||||
});
|
||||
}
|
||||
|
||||
/** 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 :
|
||||
* 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 {
|
||||
this.npcService.getByCampaign(campaignId).pipe(
|
||||
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).
|
||||
@@ -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 {
|
||||
if (!this.campaign || !arc.id) return;
|
||||
this.router.navigate(['/campaigns', this.campaign.id, 'arcs', arc.id]);
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { CampaignImportService } from '../../../services/campaign-import.service';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
@@ -106,7 +105,6 @@ export class CampaignImportComponent implements OnInit {
|
||||
private router: Router,
|
||||
private service: CampaignImportService,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
@@ -122,7 +120,7 @@ export class CampaignImportComponent implements OnInit {
|
||||
|
||||
// Pré-chargement de l'arborescence existante (pour fusionner à la revue).
|
||||
// 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)))
|
||||
.subscribe(data => this.existingData = data);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
@@ -42,7 +41,6 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
@@ -65,7 +63,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
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 }) => {
|
||||
const currentArc = treeData.arcs.find(a => a.id === this.arcId);
|
||||
this.arcName = currentArc?.name ?? '';
|
||||
|
||||
@@ -7,7 +7,6 @@ import { switchMap } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
@@ -87,7 +86,6 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
@@ -133,7 +131,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
chapter: this.campaignService.getChapterById(this.chapterId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
|
||||
@@ -5,7 +5,6 @@ import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule, ArrowLeft } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
@@ -70,7 +69,6 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
@@ -94,7 +92,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
chapter: this.campaignService.getChapterById(this.chapterId),
|
||||
scenes: this.campaignService.getScenes(this.chapterId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, 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 }) => {
|
||||
this.chapter = chapter;
|
||||
this.scenes = scenes;
|
||||
|
||||
@@ -7,7 +7,6 @@ import { LucideAngularModule, Pencil, Network, Trash2, Lock } from 'lucide-angul
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { resolveCampaignIcon } from '../../campaign-icons';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
@@ -51,7 +50,6 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
@@ -83,7 +81,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
chapter: this.campaignService.getChapterById(this.chapterId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
|
||||
@@ -24,28 +24,32 @@
|
||||
@if (total === 0) {
|
||||
<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) {
|
||||
<h2 class="sbl-folder">
|
||||
<lucide-icon [img]="Folder" [size]="14"></lucide-icon>
|
||||
{{ g.folder.split('/').join(' / ') }}
|
||||
<span class="sbl-folder-count">{{ g.enemies.length }}</span>
|
||||
<span class="sbl-folder-count">{{ g.items.length }}</span>
|
||||
</h2>
|
||||
} @else if (groups.length > 1) {
|
||||
<h2 class="sbl-folder sbl-folder--none">{{ 'enemyList.unclassified' | translate }}</h2>
|
||||
}
|
||||
@for (e of g.enemies; track e.id) {
|
||||
<button class="sbl-item" (click)="open(e)">
|
||||
<lucide-icon [img]="Skull" [size]="16"></lucide-icon>
|
||||
<span class="sbl-item-name">{{ e.name }}</span>
|
||||
@if (e.level) {
|
||||
<span class="sbl-item-level">{{ 'enemyList.levelShort' | translate:{ level: e.level } }}</span>
|
||||
}
|
||||
<span class="sbl-del" (click)="remove(e, $event)" [title]="'common.delete' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
<div class="sbl-group" cdkDropList [id]="'enemy-group-' + gi"
|
||||
[cdkDropListData]="g.items" [cdkDropListConnectedTo]="groupListIds"
|
||||
(cdkDropListDropped)="drop(g, $event)">
|
||||
@for (e of g.items; track e.id) {
|
||||
<button class="sbl-item" cdkDrag [cdkDragData]="e" (click)="open(e)">
|
||||
<lucide-icon [img]="Skull" [size]="16"></lucide-icon>
|
||||
<span class="sbl-item-name">{{ e.name }}</span>
|
||||
@if (e.level) {
|
||||
<span class="sbl-item-level">{{ 'enemyList.levelShort' | translate:{ level: e.level } }}</span>
|
||||
}
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
|
||||
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 { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
@@ -8,12 +11,7 @@ import { CampaignSidebarService } from '../../../services/campaign-sidebar.servi
|
||||
import { Enemy } from '../../../services/enemy.model';
|
||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||
import { FileDropDirective } from '../../../shared/file-drop.directive';
|
||||
|
||||
/** Groupe d'affichage : un dossier (« Démons »…) et ses ennemis. */
|
||||
interface FolderGroup {
|
||||
folder: string;
|
||||
enemies: Enemy[];
|
||||
}
|
||||
import { FolderGroup, groupByFolder } from '../../../shared/folder-grouping.util';
|
||||
|
||||
/**
|
||||
* Liste des ennemis d'une campagne, groupés par dossier (comme les PNJ).
|
||||
@@ -21,7 +19,7 @@ interface FolderGroup {
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-enemy-list',
|
||||
imports: [LucideAngularModule, TranslatePipe, FileDropDirective],
|
||||
imports: [LucideAngularModule, TranslatePipe, FileDropDirective, CdkDropList, CdkDrag],
|
||||
templateUrl: './enemy-list.component.html',
|
||||
styleUrls: ['./enemy-list.component.scss']
|
||||
})
|
||||
@@ -38,7 +36,7 @@ export class EnemyListComponent implements OnInit {
|
||||
|
||||
campaignId = '';
|
||||
/** Groupes triés par nom de dossier ; les non-classés en dernier (folder = ''). */
|
||||
groups: FolderGroup[] = [];
|
||||
groups: FolderGroup<Enemy>[] = [];
|
||||
total = 0;
|
||||
|
||||
constructor(
|
||||
@@ -47,7 +45,9 @@ export class EnemyListComponent implements OnInit {
|
||||
private service: EnemyService,
|
||||
private campaignSidebar: CampaignSidebarService,
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private translate: TranslateService
|
||||
private translate: TranslateService,
|
||||
private dataSync: DataSyncService,
|
||||
private destroyRef: DestroyRef
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -56,41 +56,44 @@ export class EnemyListComponent implements OnInit {
|
||||
this.campaignSidebar.show(this.campaignId);
|
||||
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 {
|
||||
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 = []
|
||||
});
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
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
|
||||
* le bestiaire. Upsert côté backend (dédup par référence).
|
||||
|
||||
@@ -6,7 +6,6 @@ import { LucideAngularModule, ArrowLeft, Upload, Trash2, Send, FileText, Loader,
|
||||
import { NotebookService } from '../../../services/notebook.service';
|
||||
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
import { NotebookArchive, NotebookDetail, NotebookSource, NotebookMessage } from '../../../services/notebook.model';
|
||||
@@ -66,7 +65,6 @@ export class NotebookDetailComponent implements OnInit {
|
||||
private service: NotebookService,
|
||||
private campaignSidebar: CampaignSidebarService,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private enemyService: EnemyService,
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
@@ -84,7 +82,7 @@ export class NotebookDetailComponent implements OnInit {
|
||||
}
|
||||
|
||||
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({
|
||||
next: (data) => { this.arcs = data.arcs; this.chaptersByArc = data.chaptersByArc; },
|
||||
error: () => { /* cibles indisponibles : les cartes le signaleront */ }
|
||||
|
||||
@@ -20,25 +20,29 @@
|
||||
@if (total === 0) {
|
||||
<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) {
|
||||
<h2 class="sbl-folder">
|
||||
<lucide-icon [img]="Folder" [size]="14"></lucide-icon>
|
||||
{{ g.folder }}
|
||||
<span class="sbl-folder-count">{{ g.npcs.length }}</span>
|
||||
<span class="sbl-folder-count">{{ g.items.length }}</span>
|
||||
</h2>
|
||||
} @else if (groups.length > 1) {
|
||||
<h2 class="sbl-folder sbl-folder--none">{{ 'npcList.unclassified' | translate }}</h2>
|
||||
}
|
||||
@for (n of g.npcs; track n.id) {
|
||||
<button class="sbl-item" (click)="open(n)">
|
||||
<lucide-icon [img]="Drama" [size]="16"></lucide-icon>
|
||||
<span class="sbl-item-name">{{ n.name }}</span>
|
||||
<span class="sbl-del" (click)="remove(n, $event)" [title]="'common.delete' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
<div class="sbl-group" cdkDropList [id]="'npc-group-' + gi"
|
||||
[cdkDropListData]="g.items" [cdkDropListConnectedTo]="groupListIds"
|
||||
(cdkDropListDropped)="drop(g, $event)">
|
||||
@for (n of g.items; track n.id) {
|
||||
<button class="sbl-item" cdkDrag [cdkDragData]="n" (click)="open(n)">
|
||||
<lucide-icon [img]="Drama" [size]="16"></lucide-icon>
|
||||
<span class="sbl-item-name">{{ n.name }}</span>
|
||||
<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>
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
|
||||
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 { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||
import { Npc } from '../../../services/npc.model';
|
||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
/** Groupe d'affichage : un dossier (« Bard's Gate »…) et ses PNJ. */
|
||||
interface FolderGroup {
|
||||
folder: string;
|
||||
npcs: Npc[];
|
||||
}
|
||||
import { FolderGroup, groupByFolder } from '../../../shared/folder-grouping.util';
|
||||
|
||||
/**
|
||||
* Vue d'ensemble des PNJ d'une campagne, groupés par dossier — pendant « page »
|
||||
@@ -21,7 +19,7 @@ interface FolderGroup {
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-npc-list',
|
||||
imports: [LucideAngularModule, TranslatePipe],
|
||||
imports: [LucideAngularModule, TranslatePipe, CdkDropList, CdkDrag],
|
||||
templateUrl: './npc-list.component.html',
|
||||
styleUrls: ['./npc-list.component.scss']
|
||||
})
|
||||
@@ -34,7 +32,7 @@ export class NpcListComponent implements OnInit {
|
||||
|
||||
campaignId = '';
|
||||
/** Groupes triés par nom de dossier ; les non-classés en dernier (folder = ''). */
|
||||
groups: FolderGroup[] = [];
|
||||
groups: FolderGroup<Npc>[] = [];
|
||||
total = 0;
|
||||
|
||||
constructor(
|
||||
@@ -43,7 +41,9 @@ export class NpcListComponent implements OnInit {
|
||||
private service: NpcService,
|
||||
private campaignSidebar: CampaignSidebarService,
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private translate: TranslateService
|
||||
private translate: TranslateService,
|
||||
private dataSync: DataSyncService,
|
||||
private destroyRef: DestroyRef
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -52,41 +52,42 @@ export class NpcListComponent implements OnInit {
|
||||
this.campaignSidebar.show(this.campaignId);
|
||||
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 {
|
||||
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 = []
|
||||
});
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
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 {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'npcs', n.id]);
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ export class PlaythroughDetailComponent implements OnInit, OnDestroy {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
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),
|
||||
sessions: this.sessionService.getSessions(this.playthroughId).pipe(catchError(() => of([] as Session[]))),
|
||||
characters: this.characterService.getByPlaythrough(this.playthroughId).pipe(catchError(() => of([] as Character[]))),
|
||||
|
||||
@@ -5,7 +5,6 @@ import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule, ArrowLeft } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
@@ -37,7 +36,6 @@ export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
@@ -63,7 +61,7 @@ export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
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)
|
||||
}).subscribe(({ campaign, allCampaigns, treeData, playthrough }) => {
|
||||
this.playthrough = playthrough;
|
||||
|
||||
@@ -6,7 +6,6 @@ import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
@@ -41,7 +40,6 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
@@ -65,7 +63,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
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 }) => {
|
||||
const currentChapter = (treeData.chaptersByArc[this.arcId] ?? []).find(c => c.id === this.chapterId);
|
||||
this.chapterName = currentChapter?.name ?? '';
|
||||
|
||||
@@ -8,7 +8,6 @@ import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { StoredFileService } from '../../../services/stored-file.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
@@ -97,7 +96,6 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private storedFileService: StoredFileService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
@@ -154,7 +152,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
scene: this.campaignService.getSceneById(this.sceneId),
|
||||
chapterScenes: this.campaignService.getScenes(this.chapterId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
|
||||
@@ -7,7 +7,6 @@ import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { resolveCampaignIcon } from '../../campaign-icons';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
@@ -51,7 +50,6 @@ export class SceneViewComponent implements OnInit, OnDestroy {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
@@ -86,7 +84,7 @@ export class SceneViewComponent implements OnInit, OnDestroy {
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
scene: this.campaignService.getSceneById(this.sceneId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
|
||||
@@ -48,9 +48,15 @@
|
||||
</button>
|
||||
</div>
|
||||
@if (subfolders.length > 0) {
|
||||
<div class="items-grid">
|
||||
@for (sub of subfolders; track sub) {
|
||||
<div class="node-card" (click)="navigateToSubfolder(sub.id!)">
|
||||
<div class="items-grid" cdkDropList cdkDropListOrientation="mixed"
|
||||
[cdkDropListData]="subfolders" [cdkDropListEnterPredicate]="acceptFolders"
|
||||
(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>
|
||||
<span class="node-name">{{ sub.name }}</span>
|
||||
</div>
|
||||
@@ -73,9 +79,12 @@
|
||||
</button>
|
||||
</div>
|
||||
@if (pages.length > 0) {
|
||||
<div class="items-grid">
|
||||
@for (page of pages; track page) {
|
||||
<div class="node-card" (click)="navigateToPage(page.id!)">
|
||||
<div class="items-grid" cdkDropList cdkDropListOrientation="mixed"
|
||||
[cdkDropListData]="pages" [cdkDropListConnectedTo]="subfolderListIds"
|
||||
[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>
|
||||
<span class="node-name">{{ page.title }}</span>
|
||||
</div>
|
||||
|
||||
@@ -152,3 +152,22 @@
|
||||
font-size: 0.9rem;
|
||||
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); }
|
||||
|
||||
@@ -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 { 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 { LoreService } from '../../services/lore.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 { Page } from '../../services/page.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
import { byOrder } from '../../shared/folder-grouping.util';
|
||||
import { resolveIcon } from '../lore-icons';
|
||||
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
@@ -25,7 +29,7 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-folder-view',
|
||||
imports: [LucideAngularModule, TranslatePipe],
|
||||
imports: [LucideAngularModule, TranslatePipe, CdkDropList, CdkDrag],
|
||||
templateUrl: './folder-view.component.html',
|
||||
styleUrls: ['./folder-view.component.scss']
|
||||
})
|
||||
@@ -36,6 +40,7 @@ export class FolderViewComponent implements OnInit, OnDestroy {
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Plus = Plus;
|
||||
readonly ChevronRight = ChevronRight;
|
||||
readonly GripVertical = GripVertical;
|
||||
|
||||
loreId = '';
|
||||
folderId = '';
|
||||
@@ -55,14 +60,17 @@ export class FolderViewComponent implements OnInit, OnDestroy {
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private translate: TranslateService
|
||||
private translate: TranslateService,
|
||||
private dataSync: DataSyncService,
|
||||
private destroyRef: DestroyRef
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.dataSync.onChange(this.destroyRef, () => this.load());
|
||||
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
|
||||
// Réagit aux changements de :folderId pour que la navigation d'un dossier
|
||||
// à un autre via la sidebar ne démonte/remonte pas le composant à blanc.
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
|
||||
const next = pm.get('folderId')!;
|
||||
if (next !== this.folderId) {
|
||||
this.folderId = next;
|
||||
@@ -82,8 +90,9 @@ export class FolderViewComponent implements OnInit, OnDestroy {
|
||||
this.lore = sidebar.lore;
|
||||
this.node = node;
|
||||
this.pageTitleService.set(node.name);
|
||||
this.subfolders = sidebar.nodes.filter(n => n.parentId === this.folderId);
|
||||
this.pages = sidebar.pages.filter(p => p.nodeId === this.folderId);
|
||||
// Tri par `order` — cohérent avec l'arbre (comparateur `byOrder` mutualisé).
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -114,6 +123,49 @@ export class FolderViewComponent implements OnInit, OnDestroy {
|
||||
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 {
|
||||
this.router.navigate(['/lore', this.loreId, 'folders', id]);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { LayoutService } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { Lore, LoreNode } from '../../services/lore.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
import { byOrder } from '../../shared/folder-grouping.util';
|
||||
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
@Component({
|
||||
@@ -68,7 +69,7 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
|
||||
// Bug d'affichage corrigé : on ne liste ici que les dossiers racine
|
||||
// (les sous-dossiers apparaissent dans l'arbre de la sidebar quand on
|
||||
// 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.pageTitleService.set(data.lore.name);
|
||||
// On sort du mode édition si on change de Lore en cours d'édition.
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PageService } from '../services/page.service';
|
||||
import { Lore, LoreNode } from '../services/lore.model';
|
||||
import { Template } from '../services/template.model';
|
||||
import { Page } from '../services/page.model';
|
||||
import { byOrder } from '../shared/folder-grouping.util';
|
||||
import {
|
||||
SecondarySidebarConfig, TreeItem, GlobalItem, BottomPanel
|
||||
} from '../services/layout.service';
|
||||
@@ -47,6 +48,8 @@ export function loadLoreSidebarData(
|
||||
export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarConfig {
|
||||
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).
|
||||
const pagesByNode = new Map<string, Page[]>();
|
||||
for (const p of pages) {
|
||||
@@ -68,14 +71,15 @@ export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarC
|
||||
* hiérarchie) — "Nouveau sous-dossier" et "Nouvelle page".
|
||||
*/
|
||||
const buildFolderItem = (node: LoreNode): TreeItem => {
|
||||
const subFolders = childrenByParent.get(node.id!) ?? [];
|
||||
const nodePages = pagesByNode.get(node.id!) ?? [];
|
||||
const subFolders = (childrenByParent.get(node.id!) ?? []).slice().sort(byOrder);
|
||||
const nodePages = (pagesByNode.get(node.id!) ?? []).slice().sort(byOrder);
|
||||
const children: TreeItem[] = [
|
||||
...subFolders.map(buildFolderItem),
|
||||
...nodePages.map(p => ({
|
||||
id: `page-${p.id}`,
|
||||
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,
|
||||
@@ -88,6 +92,8 @@ export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarC
|
||||
route: `/lore/${lore.id}/folders/${node.id}`,
|
||||
meta: nodePages.length > 0 ? String(nodePages.length) : undefined,
|
||||
children,
|
||||
dragKind: 'folder', dragId: node.id,
|
||||
dropKinds: ['folder', 'page'], dropParentId: node.id,
|
||||
createActions: [
|
||||
{
|
||||
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).
|
||||
const rootFolders = childrenByParent.get('__root__') ?? [];
|
||||
const rootFolders = (childrenByParent.get('__root__') ?? []).slice().sort(byOrder);
|
||||
const treeItems: TreeItem[] = rootFolders.map(buildFolderItem);
|
||||
|
||||
const globalItems: GlobalItem[] = allLores.map(l => ({
|
||||
@@ -116,7 +122,6 @@ export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarC
|
||||
const templatesPanel: BottomPanel = {
|
||||
id: 'templates',
|
||||
title: 'Templates',
|
||||
initiallyOpen: true,
|
||||
headerAction: {
|
||||
label: 'Nouveau template',
|
||||
route: `/lore/${lore.id}/templates/create`
|
||||
@@ -139,7 +144,11 @@ export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarC
|
||||
globalItems,
|
||||
globalBackLabel: 'Tous les lores',
|
||||
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! }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Injectable } from '@angular/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { forkJoin, Subscription } from 'rxjs';
|
||||
import { CampaignService } from './campaign.service';
|
||||
import { CharacterService } from './character.service';
|
||||
import { NpcService } from './npc.service';
|
||||
import { RandomTableService } from './random-table.service';
|
||||
import { EnemyService } from './enemy.service';
|
||||
@@ -27,7 +26,6 @@ import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../campaigns/c
|
||||
export class CampaignSidebarService {
|
||||
constructor(
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
@@ -47,7 +45,6 @@ export class CampaignSidebarService {
|
||||
treeData: loadCampaignTreeData(
|
||||
this.campaignService,
|
||||
campaignId,
|
||||
this.characterService,
|
||||
this.npcService,
|
||||
this.randomTableService,
|
||||
this.enemyService
|
||||
|
||||
@@ -47,6 +47,11 @@ export class CampaignService {
|
||||
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> {
|
||||
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`);
|
||||
}
|
||||
|
||||
/** 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 ==========
|
||||
/**
|
||||
* 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`);
|
||||
}
|
||||
|
||||
/** 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 ==========
|
||||
getScenes(chapterId: string): Observable<Scene[]> {
|
||||
const params = new HttpParams().set('chapterId', chapterId);
|
||||
@@ -144,6 +159,11 @@ export class CampaignService {
|
||||
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[]> {
|
||||
const params = new HttpParams().set('q', q);
|
||||
return this.http.get<Campaign[]>(`${this.apiUrl}/search`, { params });
|
||||
|
||||
38
web/src/app/services/data-sync.service.ts
Normal file
38
web/src/app/services/data-sync.service.ts
Normal 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() });
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,11 @@ export class EnemyService {
|
||||
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). */
|
||||
search(q: string): Observable<Enemy[]> {
|
||||
return this.http.get<Enemy[]>(`${this.apiUrl}/search`, { params: { q } });
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
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 {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -9,6 +18,14 @@ export interface TreeItem {
|
||||
isAction?: boolean; // style "action" (ex: "+ Nouveau chapitre")
|
||||
/** Clé d'icône optionnelle (ex: "users"). Résolue par le composant via `resolveIcon`. */
|
||||
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). */
|
||||
meta?: string;
|
||||
/**
|
||||
@@ -66,7 +83,6 @@ export interface BottomPanel {
|
||||
id: string; // identifiant pour mémoriser l'état ouvert/fermé
|
||||
title: string;
|
||||
items: BottomPanelItem[];
|
||||
initiallyOpen?: boolean;
|
||||
/** Action "+" inline dans le header — créer un item sans déplier le panneau. */
|
||||
headerAction?: { label: string; route: string };
|
||||
}
|
||||
@@ -87,6 +103,12 @@ export interface SecondarySidebarConfig {
|
||||
bottomPanel?: BottomPanel; // optionnel : présent côté Lore (Templates)
|
||||
/** @deprecated Remplacé par bottomPanel. Gardé pour compat des callers campagne. */
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface LoreNode {
|
||||
/** ID du dossier parent (null = racine). */
|
||||
parentId?: string | null;
|
||||
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. */
|
||||
type?: string;
|
||||
description?: string;
|
||||
|
||||
@@ -117,6 +117,15 @@ export class LoreService {
|
||||
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> {
|
||||
return this.http.get<LoreNodeDeletionImpact>(`${this.nodesUrl}/${id}/deletion-impact`);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,11 @@ export class NpcService {
|
||||
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). */
|
||||
search(q: string): Observable<Npc[]> {
|
||||
return this.http.get<Npc[]>(`${this.apiUrl}/search`, { params: { q } });
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface Page {
|
||||
nodeId: string;
|
||||
templateId?: string | null;
|
||||
title: string;
|
||||
/** Position de la page dans son dossier (glisser-déposer). */
|
||||
order?: number;
|
||||
values?: Record<string, string>;
|
||||
/**
|
||||
* Pour chaque champ IMAGE du template, la liste ordonnee des IDs d'images
|
||||
|
||||
@@ -59,6 +59,15 @@ export class PageService {
|
||||
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[]> {
|
||||
const params = new HttpParams().set('q', q);
|
||||
return this.http.get<Page[]>(`${this.apiUrl}/search`, { params });
|
||||
|
||||
@@ -32,6 +32,11 @@ export class RandomTableService {
|
||||
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). */
|
||||
search(q: string): Observable<RandomTable[]> {
|
||||
return this.http.get<RandomTable[]>(`${this.apiUrl}/search`, { params: { q } });
|
||||
|
||||
67
web/src/app/services/sidebar-reorder.service.ts
Normal file
67
web/src/app/services/sidebar-reorder.service.ts
Normal 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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,8 +116,8 @@ export class SessionReferencePanelComponent implements OnChanges {
|
||||
private ensureTreeLoaded(): void {
|
||||
if (this.treeLoaded || this.loadingTree || !this.campaignId) return;
|
||||
this.loadingTree = true;
|
||||
loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, undefined, this.enemyService).pipe(
|
||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [], enemies: [] } as CampaignTreeData))
|
||||
loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, undefined, this.enemyService).pipe(
|
||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, npcs: [], randomTables: [], enemies: [] } as CampaignTreeData))
|
||||
).subscribe(data => {
|
||||
this.treeData = data;
|
||||
this.loadingTree = false;
|
||||
|
||||
46
web/src/app/shared/folder-grouping.util.ts
Normal file
46
web/src/app/shared/folder-grouping.util.ts
Normal 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;
|
||||
}
|
||||
@@ -33,75 +33,33 @@
|
||||
}
|
||||
</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">
|
||||
@for (item of items; track item) {
|
||||
<ng-container *ngTemplateOutlet="treeNode; context: { $implicit: item, level: 0 }"></ng-container>
|
||||
}
|
||||
</div>
|
||||
<!-- Template récursif : un noeud d'arbre rend son bouton, puis ses enfants via ce même template -->
|
||||
<ng-template #treeNode let-item let-level="level">
|
||||
@if (level === 0 && item.sectionHeaderBefore) {
|
||||
<div class="tree-section-header">
|
||||
{{ item.sectionHeaderBefore }}
|
||||
</div>
|
||||
}
|
||||
<div class="tree-item" [style.padding-left.px]="level * 12">
|
||||
<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>
|
||||
<div class="tree-list" cdkDropList
|
||||
id="sidebar-root"
|
||||
[cdkDropListData]="rootDragData"
|
||||
[cdkDropListConnectedTo]="dropListIds"
|
||||
[cdkDropListDisabled]="!dndEnabled()"
|
||||
[cdkDropListEnterPredicate]="rootEnterPredicate"
|
||||
(cdkDropListDropped)="onDrop(null, $event)">
|
||||
@for (item of items; track item.id; let first = $first) {
|
||||
<!-- En-tête de section RENDU HORS du nœud (sinon il serait emporté par le
|
||||
drag du premier item). C'est un élément non-déplaçable de la drop list. -->
|
||||
@if (item.sectionHeaderBefore) {
|
||||
<div class="tree-section-header" [class.first]="first">{{ item.sectionHeaderBefore }}</div>
|
||||
}
|
||||
@if (item.isAction || !isExpandable(item)) {
|
||||
<span class="chevron-spacer"></span>
|
||||
@if (dndEnabled() && item.dragKind) {
|
||||
<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>
|
||||
</ng-template>
|
||||
</div>
|
||||
<!-- Panneau bas (ex: Templates) ------------------------------------ -->
|
||||
@if (bottomPanel) {
|
||||
<section class="bottom-panel">
|
||||
|
||||
@@ -50,23 +50,6 @@
|
||||
&: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 {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
@@ -132,153 +115,31 @@
|
||||
|
||||
.tree {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
overflow-y: auto;
|
||||
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).
|
||||
// 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).
|
||||
// En-tête de section (NARRATION / PERSONNAGES / OUTILS) — rendu ici, hors des nœuds.
|
||||
.tree-section-header {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #9ca3af;
|
||||
padding: 0.6rem 0.5rem 0.3rem;
|
||||
}
|
||||
.tree > .tree-section-header:not(:first-child) {
|
||||
border-top: 1px solid #374151;
|
||||
margin-top: 0.35rem;
|
||||
padding-top: 0.6rem;
|
||||
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 {
|
||||
border-top: 1px solid #1e1e3a;
|
||||
@@ -372,3 +233,7 @@
|
||||
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; }
|
||||
|
||||
@@ -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 { Router } from '@angular/router';
|
||||
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 { 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';
|
||||
|
||||
@Component({
|
||||
selector: 'app-secondary-sidebar',
|
||||
imports: [CommonModule, LucideAngularModule, TranslatePipe],
|
||||
imports: [CommonModule, LucideAngularModule, TranslatePipe, CdkDropList, CdkDrag, forwardRef(() => SidebarTreeNodeComponent)],
|
||||
templateUrl: './secondary-sidebar.component.html',
|
||||
styleUrls: ['./secondary-sidebar.component.scss']
|
||||
})
|
||||
@@ -18,6 +21,10 @@ export class SecondarySidebarComponent implements OnDestroy {
|
||||
@Input() titleRoute: string | null = null;
|
||||
@Input() createActions: SidebarAction[] = [];
|
||||
@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>();
|
||||
|
||||
/** true = ouvert (on affiche les items) ; false = replié (titre seul). */
|
||||
@@ -47,16 +54,35 @@ export class SecondarySidebarComponent implements OnDestroy {
|
||||
|
||||
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[]) {
|
||||
this._items = value ?? [];
|
||||
this._buildDragMaps();
|
||||
this.autoExpandActiveAncestors();
|
||||
}
|
||||
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(
|
||||
private router: Router,
|
||||
private layoutService: LayoutService,
|
||||
private elementRef: ElementRef<HTMLElement>
|
||||
private elementRef: ElementRef<HTMLElement>,
|
||||
private reorderService: SidebarReorderService
|
||||
) {
|
||||
try {
|
||||
const stored = localStorage.getItem(SecondarySidebarComponent.WIDTH_STORAGE_KEY);
|
||||
@@ -199,6 +225,109 @@ export class SecondarySidebarComponent implements OnDestroy {
|
||||
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. */
|
||||
isExpandable(item: TreeItem): boolean {
|
||||
return this.hasChildren(item);
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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);
|
||||
}
|
||||
@@ -260,7 +260,8 @@
|
||||
},
|
||||
"secondarySidebar": {
|
||||
"backToCampaignHome": "Back to campaign home",
|
||||
"resizeHandle": "Drag to resize"
|
||||
"resizeHandle": "Drag to resize",
|
||||
"dragHandle": "Drag to reorder"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"ariaLabel": "Breadcrumb"
|
||||
@@ -592,6 +593,8 @@
|
||||
"defaultPlaythroughName": "New playthrough {{n}}",
|
||||
"foundryExport": "Export to Foundry",
|
||||
"foundryExporting": "Exporting…",
|
||||
"pdfExport": "Export to PDF",
|
||||
"pdfExporting": "Generating…",
|
||||
"gameSystemChange": {
|
||||
"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.",
|
||||
@@ -1496,6 +1499,7 @@
|
||||
},
|
||||
"campaignTree": {
|
||||
"npcs": "NPCs",
|
||||
"unclassified": "Unfiled",
|
||||
"newNpc": "New NPC",
|
||||
"newScene": "New scene",
|
||||
"newQuest": "New quest",
|
||||
|
||||
@@ -260,7 +260,8 @@
|
||||
},
|
||||
"secondarySidebar": {
|
||||
"backToCampaignHome": "Retour à l'accueil de la campagne",
|
||||
"resizeHandle": "Glissez pour redimensionner"
|
||||
"resizeHandle": "Glissez pour redimensionner",
|
||||
"dragHandle": "Glissez pour réordonner"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"ariaLabel": "Fil d'Ariane"
|
||||
@@ -592,6 +593,8 @@
|
||||
"defaultPlaythroughName": "Nouvelle partie {{n}}",
|
||||
"foundryExport": "Exporter pour Foundry",
|
||||
"foundryExporting": "Export…",
|
||||
"pdfExport": "Exporter en PDF",
|
||||
"pdfExporting": "Génération…",
|
||||
"gameSystemChange": {
|
||||
"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.",
|
||||
@@ -1496,6 +1499,7 @@
|
||||
},
|
||||
"campaignTree": {
|
||||
"npcs": "PNJ",
|
||||
"unclassified": "Sans dossier",
|
||||
"newNpc": "Nouveau PNJ",
|
||||
"newScene": "Nouvelle scène",
|
||||
"newQuest": "Nouvelle quête",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"campaignTree": {
|
||||
"npcs": "NPCs",
|
||||
"unclassified": "Unfiled",
|
||||
"newNpc": "New NPC",
|
||||
"newScene": "New scene",
|
||||
"newQuest": "New quest",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"campaignTree": {
|
||||
"npcs": "PNJ",
|
||||
"unclassified": "Sans dossier",
|
||||
"newNpc": "Nouveau PNJ",
|
||||
"newScene": "Nouvelle scène",
|
||||
"newQuest": "Nouvelle quête",
|
||||
|
||||
@@ -60,6 +60,8 @@
|
||||
"defaultPlaythroughName": "New playthrough {{n}}",
|
||||
"foundryExport": "Export to Foundry",
|
||||
"foundryExporting": "Exporting…",
|
||||
"pdfExport": "Export to PDF",
|
||||
"pdfExporting": "Generating…",
|
||||
"gameSystemChange": {
|
||||
"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.",
|
||||
|
||||
@@ -60,6 +60,8 @@
|
||||
"defaultPlaythroughName": "Nouvelle partie {{n}}",
|
||||
"foundryExport": "Exporter pour Foundry",
|
||||
"foundryExporting": "Export…",
|
||||
"pdfExport": "Exporter en PDF",
|
||||
"pdfExporting": "Génération…",
|
||||
"gameSystemChange": {
|
||||
"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.",
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
},
|
||||
"secondarySidebar": {
|
||||
"backToCampaignHome": "Back to campaign home",
|
||||
"resizeHandle": "Drag to resize"
|
||||
"resizeHandle": "Drag to resize",
|
||||
"dragHandle": "Drag to reorder"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"ariaLabel": "Breadcrumb"
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
},
|
||||
"secondarySidebar": {
|
||||
"backToCampaignHome": "Retour à l'accueil de la campagne",
|
||||
"resizeHandle": "Glissez pour redimensionner"
|
||||
"resizeHandle": "Glissez pour redimensionner",
|
||||
"dragHandle": "Glissez pour réordonner"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"ariaLabel": "Fil d'Ariane"
|
||||
|
||||
@@ -19,3 +19,48 @@ body {
|
||||
background: #0a0a14;
|
||||
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; }
|
||||
|
||||
Reference in New Issue
Block a user