diff --git a/core/pom.xml b/core/pom.xml index 6467383..1e24650 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -148,6 +148,23 @@ tink 1.21.0 + + + + com.openhtmltopdf + openhtmltopdf-pdfbox + 1.0.10 + + + + com.twelvemonkeys.imageio + imageio-webp + 3.12.0 + diff --git a/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java b/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java index 972d460..75b3354 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java @@ -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 orderedIds) { + ReorderSupport.reorder(orderedIds, + id -> arcRepository.findById(id).orElse(null), + (arc, i) -> arc.setOrder(i), + arcRepository::save); + } } diff --git a/core/src/main/java/com/loremind/application/campaigncontext/ChapterService.java b/core/src/main/java/com/loremind/application/campaigncontext/ChapterService.java index c9a0ab4..354bece 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/ChapterService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/ChapterService.java @@ -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 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); + } } diff --git a/core/src/main/java/com/loremind/application/campaigncontext/EnemyService.java b/core/src/main/java/com/loremind/application/campaigncontext/EnemyService.java index a7f2ad6..27dbc8f 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/EnemyService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/EnemyService.java @@ -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 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) { diff --git a/core/src/main/java/com/loremind/application/campaigncontext/NpcService.java b/core/src/main/java/com/loremind/application/campaigncontext/NpcService.java index 18de3ab..140b5ee 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/NpcService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/NpcService.java @@ -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 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 searchNpcs(String query) { if (query == null || query.isBlank()) return List.of(); return npcRepository.searchByName(query.trim()); diff --git a/core/src/main/java/com/loremind/application/campaigncontext/RandomTableService.java b/core/src/main/java/com/loremind/application/campaigncontext/RandomTableService.java index 0194dc2..82147a6 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/RandomTableService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/RandomTableService.java @@ -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 orderedIds) { + ReorderSupport.reorder(orderedIds, + id -> repository.findById(id).orElse(null), + (table, i) -> table.setOrder(i), + repository::save); + } + public List searchTables(String query) { if (query == null || query.isBlank()) return List.of(); return repository.searchByName(query.trim()); diff --git a/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java b/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java index 7dbfeb8..39f4968 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java @@ -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 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). diff --git a/core/src/main/java/com/loremind/application/lorecontext/LoreNodeService.java b/core/src/main/java/com/loremind/application/lorecontext/LoreNodeService.java index b5f6be0..bb99ae9 100644 --- a/core/src/main/java/com/loremind/application/lorecontext/LoreNodeService.java +++ b/core/src/main/java/com/loremind/application/lorecontext/LoreNodeService.java @@ -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 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 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 getLoreNodesByLoreId(String loreId) { return loreNodeRepository.findByLoreId(loreId); } diff --git a/core/src/main/java/com/loremind/application/lorecontext/PageService.java b/core/src/main/java/com/loremind/application/lorecontext/PageService.java index afc9838..fe5ff86 100644 --- a/core/src/main/java/com/loremind/application/lorecontext/PageService.java +++ b/core/src/main/java/com/loremind/application/lorecontext/PageService.java @@ -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 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 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); + } } diff --git a/core/src/main/java/com/loremind/domain/lorecontext/LoreNode.java b/core/src/main/java/com/loremind/domain/lorecontext/LoreNode.java index 8dd71f5..58b293f 100644 --- a/core/src/main/java/com/loremind/domain/lorecontext/LoreNode.java +++ b/core/src/main/java/com/loremind/domain/lorecontext/LoreNode.java @@ -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; } diff --git a/core/src/main/java/com/loremind/domain/lorecontext/Page.java b/core/src/main/java/com/loremind/domain/lorecontext/Page.java index d96f2ac..fd35821 100644 --- a/core/src/main/java/com/loremind/domain/lorecontext/Page.java +++ b/core/src/main/java/com/loremind/domain/lorecontext/Page.java @@ -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 values; diff --git a/core/src/main/java/com/loremind/domain/shared/ReorderSupport.java b/core/src/main/java/com/loremind/domain/shared/ReorderSupport.java new file mode 100644 index 0000000..96e0af1 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/shared/ReorderSupport.java @@ -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 void reorder(List orderedIds, + Function finder, + ObjIntConsumer applyOrder, + Consumer 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++; + } + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/LoreNodeJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/LoreNodeJpaEntity.java index b35e901..9a6f224 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/LoreNodeJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/LoreNodeJpaEntity.java @@ -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; diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/PageJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/PageJpaEntity.java index 9c4b554..2c3487e 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/PageJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/PageJpaEntity.java @@ -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 values; diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresLoreNodeRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresLoreNodeRepository.java index db9b5eb..98854b2 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresLoreNodeRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresLoreNodeRepository.java @@ -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(); diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresPageRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresPageRepository.java index ade5adc..469ee3b 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresPageRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresPageRepository.java @@ -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<>()) diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryExportService.java b/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryExportService.java index 061b707..821d6b5 100644 --- a/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryExportService.java +++ b/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryExportService.java @@ -99,9 +99,10 @@ public class FoundryExportService { CampaignJpaEntity campaign = campaignRepo.findById(Long.parseLong(campaignId)) .orElseThrow(() -> new NoSuchElementException("Campagne introuvable : " + campaignId)); - List npcTemplate = resolveTemplate(campaign.getGameSystemId(), true); - List enemyTemplate = resolveTemplate(campaign.getGameSystemId(), false); - String foundryActorType = resolveActorType(campaign.getGameSystemId()); + GameSystemJpaEntity gameSystem = resolveGameSystem(campaign.getGameSystemId()); + List npcTemplate = gameSystem != null ? gameSystem.getNpcTemplate() : null; + List 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 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; } diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/pdf/PdfExportService.java b/core/src/main/java/com/loremind/infrastructure/transfer/pdf/PdfExportService.java new file mode 100644 index 0000000..53c29ed --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/transfer/pdf/PdfExportService.java @@ -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. + *

+ * 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 npcTemplate = resolveTemplate(campaign.getGameSystemId(), true); + List 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 "\n" + + "\n" + + "\n" + + "" + bookmarks + "\n" + + "" + body + ""; + } + + private void cover(StringBuilder b, CampaignJpaEntity c) { + b.append("

"); + b.append("
Livret de campagne
"); + b.append("

").append(esc(c.getName())).append("

"); + if (notBlank(c.getDescription())) { + b.append("
").append(multiline(c.getDescription())).append("
"); + } + b.append("
"); + } + + // ----- 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 arcs = sortByOrder(arcRepo.findByCampaignId(campaign.getId()), ArcJpaEntity::getOrder); + if (arcs.isEmpty()) return; + + b.append("

Structure narrative

"); + 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("

") + .append("Arc") + .append(esc(arc.getName())).append("

"); + 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("
") + .append("Quête") + .append(esc(ch.getName())).append("
"); + 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("
"); + } + b.append("
"); + } + } + + private void scene(StringBuilder b, SceneJpaEntity sc) { + b.append("
") + .append("Scène") + .append(esc(sc.getName())).append("
"); + 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("
"); + } + b.append("
"); + } + + // ----- 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 values, Map> keyValueValues, + Map> imageValues, Map foundryStats) {} + + private List npcEntries(CampaignJpaEntity c) { + List 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 enemyEntries(CampaignJpaEntity c) { + List 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 rows, List template, boolean enemy) { + if (rows.isEmpty()) return; + b.append("

").append(esc(title)).append("

"); + bm.append(bookmark(title, partId, null)); + + // Groupement par dossier (dossiers tries, non-classes en dernier). + Map> byFolder = new TreeMap<>(); + List 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> e : byFolder.entrySet()) { + b.append("

").append(esc(e.getKey().replace("/", " / "))).append("

"); + 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("

Sans dossier

"); + 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 template, boolean enemy) { + // Mise en page en TABLE (portrait | contenu) : openhtmltopdf gere mal le float + // + overflow (le texte se superposait au portrait). + b.append("
"); + String portrait = imageUri(r.portraitId(), PORTRAIT_MAX); + if (portrait != null) { + b.append(""); + } + b.append("
"); + b.append("
").append(esc(r.name())); + if (notBlank(r.level())) b.append(" Niv. ").append(esc(r.level())).append(""); + b.append("
"); + + renderFields(b, template, r.values(), r.keyValueValues(), r.imageValues(), null); + + if (enemy && r.foundryStats() != null) { + Map clean = cleanStats(r.foundryStats()); + if (!clean.isEmpty()) { + b.append("
Statistiques
"); + for (Map.Entry s : clean.entrySet()) { + b.append(""); + } + b.append("
").append(esc(s.getKey())).append("") + .append(esc(s.getValue())).append("
"); + } + } + b.append("
"); + } + + /** Valeurs de stats considerees comme "vides"/bruit (masquees dans le livret). */ + private static final Set 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 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 cleanStats(Map stats) { + Map out = new LinkedHashMap<>(); + for (Map.Entry 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 pages = pageRepo.findByLoreId(lid); + if (pages.isEmpty()) return; + + b.append("

Lore

"); + bm.append(bookmark("Lore", "part-lore", null)); + + // Chemins de dossiers (LoreNode) + templates par id. + Map nodes = new HashMap<>(); + for (LoreNodeJpaEntity n : loreNodeRepo.findByLoreId(lid)) nodes.put(n.getId(), n); + Map templates = new HashMap<>(); + for (TemplateJpaEntity t : templateRepo.findByLoreId(lid)) templates.put(t.getId(), t); + + Map> byPath = new TreeMap<>(); + for (PageJpaEntity p : pages) { + byPath.computeIfAbsent(nodePath(p.getNodeId(), nodes), k -> new ArrayList<>()).add(p); + } + for (Map.Entry> e : byPath.entrySet()) { + String label = e.getKey().isEmpty() ? "Sans dossier" : e.getKey(); + b.append("

").append(esc(label)).append("

"); + e.getValue().sort(java.util.Comparator.comparingInt(PageJpaEntity::getOrder)); + for (PageJpaEntity p : e.getValue()) { + b.append("
") + .append(esc(p.getTitle())).append("
"); + List tpl = p.getTemplateId() != null + ? fieldsOf(templates.get(p.getTemplateId())) : null; + renderFields(b, tpl, p.getValues(), p.getKeyValueValues(), p.getImageValues(), p.getTableValues()); + b.append("
"); + } + } + } + + private String nodePath(Long nodeId, Map nodes) { + if (nodeId == null) return ""; + Deque 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 template, Map values, + Map> keyValueValues, + Map> imageValues, + Map>> tableValues) { + // Repli : pas de template -> paires brutes cle=valeur. + if (template == null || template.isEmpty()) { + if (values != null) { + for (Map.Entry 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 inner = keyValueValues != null ? keyValueValues.get(f.getName()) : null; + List 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("").append(esc(label)).append("") + .append(esc(v)).append(""); + } + if (rows.length() > 0) { + b.append("
").append(esc(f.getName())) + .append("
").append(rows) + .append("
"); + } + } + } + case IMAGE -> { + List ids = imageValues != null ? imageValues.get(f.getName()) : null; + illustrations(b, ids); + } + case TABLE -> { + List> data = tableValues != null ? tableValues.get(f.getName()) : null; + List cols = f.getLabels(); + if (data != null && !data.isEmpty() && cols != null && !cols.isEmpty()) { + b.append("
").append(esc(f.getName())) + .append("
"); + for (String col : cols) b.append(""); + b.append(""); + for (Map row : data) { + b.append(""); + for (String col : cols) b.append(""); + b.append(""); + } + b.append("
").append(esc(col)).append("
").append(esc(row.get(col))).append("
"); + } + } + } + } + } + + /** 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("
").append(esc(label)) + .append("
").append(multiline(value)).append("
"); + } + + /** Galerie d'illustrations (liste d'ids d'images) -> blocs image. */ + private void illustrations(StringBuilder b, List imageIds) { + if (imageIds == null) return; + for (String id : imageIds) { + String uri = imageUri(id, ILLUSTRATION_MAX); + if (uri != null) b.append("
"); + } + } + + // ====================================================================== 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 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 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 List sortByOrder(List list, java.util.function.ToIntFunction order) { + List 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 children) { + String inner = children != null ? children.get() : ""; + return "" + inner + ""; + } + + /** 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", "
"); + } + + // 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; } + """; +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/ArcController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/ArcController.java index d2f2aff..7cd3bf8 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/ArcController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/ArcController.java @@ -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 reorder(@RequestBody ReorderRequest req) { + arcService.reorderArcs(req.orderedIds()); + return ResponseEntity.noContent().build(); + } + + public record ReorderRequest(List orderedIds) {} } diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/ChapterController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/ChapterController.java index 27ae2ea..c347967 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/ChapterController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/ChapterController.java @@ -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 reorder(@RequestBody ReorderRequest req) { + chapterService.reorderChapters(req.arcId(), req.orderedIds()); + return ResponseEntity.noContent().build(); + } + + public record ReorderRequest(String arcId, List orderedIds) {} } diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/EnemyController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/EnemyController.java index 9d294c1..16e5412 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/EnemyController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/EnemyController.java @@ -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 reorder(@RequestBody ReorderRequest req) { + enemyService.reorderEnemies(req.folder(), req.orderedIds()); + return ResponseEntity.noContent().build(); + } + + public record ReorderRequest(String folder, List orderedIds) {} + private EnemyService.EnemyData toData(EnemyRequest req) { return new EnemyService.EnemyData( req.name(), req.level(), req.folder(), diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/LoreNodeController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/LoreNodeController.java index 4814d7f..913203e 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/LoreNodeController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/LoreNodeController.java @@ -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 reorder(@RequestBody ReorderRequest req) { + loreNodeService.reorderNodes(req.parentId(), req.orderedIds()); + return ResponseEntity.noContent().build(); + } + + public record ReorderRequest(String parentId, List orderedIds) {} } diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/NpcController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/NpcController.java index 3a78182..ab50a48 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/NpcController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/NpcController.java @@ -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 reorder(@RequestBody ReorderRequest req) { + npcService.reorderNpcs(req.folder(), req.orderedIds()); + return ResponseEntity.noContent().build(); + } + + public record ReorderRequest(String folder, List orderedIds) {} + private NpcService.NpcData toData(NpcDTO dto, Integer order) { return new NpcService.NpcData( dto.getName(), diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/PageController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/PageController.java index 1d2b9a8..6832f89 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/PageController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/PageController.java @@ -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 reorder(@RequestBody ReorderRequest req) { + pageService.reorderPages(req.nodeId(), req.orderedIds()); + return ResponseEntity.noContent().build(); + } + + public record ReorderRequest(String nodeId, List orderedIds) {} } diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/PdfExportController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/PdfExportController.java new file mode 100644 index 0000000..c194cc2 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/PdfExportController.java @@ -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. + *

+ * {@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 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; + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/RandomTableController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/RandomTableController.java index 9de32f8..47021b8 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/RandomTableController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/RandomTableController.java @@ -90,6 +90,15 @@ public class RandomTableController { } } + /** Réordonne les tables aléatoires : order = position. */ + @PutMapping("/reorder") + public ResponseEntity reorder(@RequestBody ReorderRequest req) { + service.reorderTables(req.orderedIds()); + return ResponseEntity.noContent().build(); + } + + public record ReorderRequest(List orderedIds) {} + public record GenerateRequest(String campaignId, String description, String diceFormula) {} public record ImproviseRequest(String campaignId, String tableName, String resultLabel, String resultDetail) {} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/SceneController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/SceneController.java index f986901..ef3b776 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/SceneController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/SceneController.java @@ -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 reorder(@RequestBody ReorderRequest req) { + sceneService.reorderScenes(req.chapterId(), req.orderedIds()); + return ResponseEntity.noContent().build(); + } + + public record ReorderRequest(String chapterId, List orderedIds) {} } diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/lorecontext/LoreNodeDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/lorecontext/LoreNodeDTO.java index 18f0faa..8b5b60f 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/dto/lorecontext/LoreNodeDTO.java +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/lorecontext/LoreNodeDTO.java @@ -14,4 +14,5 @@ public class LoreNodeDTO { private String icon; private String parentId; private String loreId; + private int order; } diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/lorecontext/PageDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/lorecontext/PageDTO.java index 88269b1..91c6e9f 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/dto/lorecontext/PageDTO.java +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/lorecontext/PageDTO.java @@ -17,6 +17,7 @@ public class PageDTO { private String nodeId; private String templateId; private String title; + private int order; private Map values; /** Pour chaque champ IMAGE du template, la liste ordonnee des IDs d'images. */ private Map> imageValues; diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/LoreNodeMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/LoreNodeMapper.java index bfece93..87e9491 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/mapper/LoreNodeMapper.java +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/LoreNodeMapper.java @@ -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(); } } diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/PageMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/PageMapper.java index 188f71a..d5512ec 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/mapper/PageMapper.java +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/PageMapper.java @@ -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())) diff --git a/core/src/main/resources/db/migration/V6__lore_order.sql b/core/src/main/resources/db/migration/V6__lore_order.sql new file mode 100644 index 0000000..63c28c3 --- /dev/null +++ b/core/src/main/resources/db/migration/V6__lore_order.sql @@ -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; diff --git a/core/src/test/java/com/loremind/infrastructure/transfer/pdf/PdfExportServiceTest.java b/core/src/test/java/com/loremind/infrastructure/transfer/pdf/PdfExportServiceTest.java new file mode 100644 index 0000000..c638188 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/transfer/pdf/PdfExportServiceTest.java @@ -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 .") + .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()))); + } +} diff --git a/web/package-lock.json b/web/package-lock.json index ab53702..910df9b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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" diff --git a/web/package.json b/web/package.json index 673e475..c1562d3 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/app/app.component.html b/web/src/app/app.component.html index 2e0305d..cda78fd 100644 --- a/web/src/app/app.component.html +++ b/web/src/app/app.component.html @@ -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"> } diff --git a/web/src/app/campaigns/arc/arc-create/arc-create.component.ts b/web/src/app/campaigns/arc/arc-create/arc-create.component.ts index f929644..a5b243c 100644 --- a/web/src/app/campaigns/arc/arc-create/arc-create.component.ts +++ b/web/src/app/campaigns/arc/arc-create/arc-create.component.ts @@ -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; diff --git a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts index 61c8836..36a5a04 100644 --- a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts +++ b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts @@ -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; diff --git a/web/src/app/campaigns/arc/arc-view/arc-view.component.html b/web/src/app/campaigns/arc/arc-view/arc-view.component.html index 68b383e..17e7e08 100644 --- a/web/src/app/campaigns/arc/arc-view/arc-view.component.html +++ b/web/src/app/campaigns/arc/arc-view/arc-view.component.html @@ -40,10 +40,12 @@

} @if (hubQuests.length > 0) { -
- @for (q of hubQuests; track q) { +
+ @for (q of hubQuests; track q.id) {
+
@if (npcs.length > 0) { -
- @for (npc of npcs; track npc) { -
- -
- {{ npc.name }} - {{ personaSnippet(npc) }} -
-
+ @for (g of npcGroups; track g.folder) { + @if (g.folder) { +

{{ g.folder.split('/').join(' / ') }}

+ } @else if (npcGroups.length > 1) { +

{{ 'campaignTree.unclassified' | translate }}

} -
+
+ @for (npc of g.items; track npc.id) { +
+ +
+ {{ npc.name }} + {{ personaSnippet(npc) }} +
+
+ } +
+ } } @if (npcs.length === 0) {
@@ -172,9 +183,10 @@
@if (arcs.length > 0) { -
- @for (arc of arcs; track arc) { -
+
+ @for (arc of arcs; track arc.id) { +
{{ arc.name }} {{ 'campaignDetail.chapters' | translate:{ n: chapterCountByArc[arc.id!] || 0 } }} diff --git a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.scss b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.scss index ab3754f..a8bd63c 100644 --- a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.scss +++ b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.scss @@ -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; } +} diff --git a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts index d63a7a6..ae5e7ac 100644 --- a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts +++ b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts @@ -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[] = []; /** 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 { @@ -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): 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]); diff --git a/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.ts b/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.ts index e185e2d..0d2cbf7 100644 --- a/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.ts +++ b/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.ts @@ -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); } diff --git a/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.ts b/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.ts index 6ae534b..c216ca2 100644 --- a/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.ts +++ b/web/src/app/campaigns/chapter/chapter-create/chapter-create.component.ts @@ -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 ?? ''; diff --git a/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.ts b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.ts index 23f2fcb..2dcbf08 100644 --- a/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.ts +++ b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.ts @@ -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; diff --git a/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.ts b/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.ts index ceb0169..572ea81 100644 --- a/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.ts +++ b/web/src/app/campaigns/chapter/chapter-graph/chapter-graph.component.ts @@ -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; diff --git a/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.ts b/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.ts index 99e0571..335d926 100644 --- a/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.ts +++ b/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.ts @@ -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; diff --git a/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.html b/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.html index eba2a76..197f784 100644 --- a/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.html +++ b/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.html @@ -24,28 +24,32 @@ @if (total === 0) {

{{ 'enemyList.empty' | translate }}

} - @for (g of groups; track g.folder) { + @for (g of groups; track g.folder; let gi = $index) { @if (g.folder) {

{{ g.folder.split('/').join(' / ') }} - {{ g.enemies.length }} + {{ g.items.length }}

} @else if (groups.length > 1) {

{{ 'enemyList.unclassified' | translate }}

} - @for (e of g.enemies; track e.id) { - - } +
+ @for (e of g.items; track e.id) { + + } +
}
diff --git a/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.ts b/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.ts index a7f8db8..34c1928 100644 --- a/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.ts +++ b/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.ts @@ -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[] = []; 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(); - 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, event: CdkDragDrop): 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). diff --git a/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.ts b/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.ts index 5e4279e..ca9de6b 100644 --- a/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.ts +++ b/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.ts @@ -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 */ } diff --git a/web/src/app/campaigns/npc/npc-list/npc-list.component.html b/web/src/app/campaigns/npc/npc-list/npc-list.component.html index dc10bd4..408ce59 100644 --- a/web/src/app/campaigns/npc/npc-list/npc-list.component.html +++ b/web/src/app/campaigns/npc/npc-list/npc-list.component.html @@ -20,25 +20,29 @@ @if (total === 0) {

{{ 'npcList.empty' | translate }}

} - @for (g of groups; track g.folder) { + @for (g of groups; track g.folder; let gi = $index) { @if (g.folder) {

{{ g.folder }} - {{ g.npcs.length }} + {{ g.items.length }}

} @else if (groups.length > 1) {

{{ 'npcList.unclassified' | translate }}

} - @for (n of g.npcs; track n.id) { - - } +
+ @for (n of g.items; track n.id) { + + } +
}
diff --git a/web/src/app/campaigns/npc/npc-list/npc-list.component.ts b/web/src/app/campaigns/npc/npc-list/npc-list.component.ts index 7c14f4d..55218fb 100644 --- a/web/src/app/campaigns/npc/npc-list/npc-list.component.ts +++ b/web/src/app/campaigns/npc/npc-list/npc-list.component.ts @@ -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[] = []; 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(); - 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, event: CdkDragDrop): 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]); } diff --git a/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.ts b/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.ts index d3a8a8e..3a7f50c 100644 --- a/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.ts +++ b/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.ts @@ -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[]))), diff --git a/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.ts b/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.ts index 287b029..ef01f7b 100644 --- a/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.ts +++ b/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.ts @@ -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; diff --git a/web/src/app/campaigns/scene/scene-create/scene-create.component.ts b/web/src/app/campaigns/scene/scene-create/scene-create.component.ts index 51c6aab..23d698c 100644 --- a/web/src/app/campaigns/scene/scene-create/scene-create.component.ts +++ b/web/src/app/campaigns/scene/scene-create/scene-create.component.ts @@ -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 ?? ''; diff --git a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.ts b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.ts index 1cd300b..8181a3c 100644 --- a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.ts +++ b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.ts @@ -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; diff --git a/web/src/app/campaigns/scene/scene-view/scene-view.component.ts b/web/src/app/campaigns/scene/scene-view/scene-view.component.ts index a93f27a..9ce89b6 100644 --- a/web/src/app/campaigns/scene/scene-view/scene-view.component.ts +++ b/web/src/app/campaigns/scene/scene-view/scene-view.component.ts @@ -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; diff --git a/web/src/app/lore/folder-view/folder-view.component.html b/web/src/app/lore/folder-view/folder-view.component.html index bf79ec4..a5cdc8b 100644 --- a/web/src/app/lore/folder-view/folder-view.component.html +++ b/web/src/app/lore/folder-view/folder-view.component.html @@ -48,9 +48,15 @@ @if (subfolders.length > 0) { -
- @for (sub of subfolders; track sub) { -
+
+ @for (sub of subfolders; track sub.id) { +
{{ sub.name }}
@@ -73,9 +79,12 @@
@if (pages.length > 0) { -
- @for (page of pages; track page) { -
+
+ @for (page of pages; track page.id) { +
{{ page.title }}
diff --git a/web/src/app/lore/folder-view/folder-view.component.scss b/web/src/app/lore/folder-view/folder-view.component.scss index 976d05f..d7196ad 100644 --- a/web/src/app/lore/folder-view/folder-view.component.scss +++ b/web/src/app/lore/folder-view/folder-view.component.scss @@ -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); } diff --git a/web/src/app/lore/folder-view/folder-view.component.ts b/web/src/app/lore/folder-view/folder-view.component.ts index 8ba10a1..9838fa0 100644 --- a/web/src/app/lore/folder-view/folder-view.component.ts +++ b/web/src/app/lore/folder-view/folder-view.component.ts @@ -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): 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): 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): 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]); } diff --git a/web/src/app/lore/lore-detail/lore-detail.component.ts b/web/src/app/lore/lore-detail/lore-detail.component.ts index a84243f..2effb80 100644 --- a/web/src/app/lore/lore-detail/lore-detail.component.ts +++ b/web/src/app/lore/lore-detail/lore-detail.component.ts @@ -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. diff --git a/web/src/app/lore/lore-sidebar.helper.ts b/web/src/app/lore/lore-sidebar.helper.ts index 2b13ba7..60b8cc9 100644 --- a/web/src/app/lore/lore-sidebar.helper.ts +++ b/web/src/app/lore/lore-sidebar.helper.ts @@ -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(); 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! } }; } diff --git a/web/src/app/services/campaign-sidebar.service.ts b/web/src/app/services/campaign-sidebar.service.ts index 52fca2a..afc0e7f 100644 --- a/web/src/app/services/campaign-sidebar.service.ts +++ b/web/src/app/services/campaign-sidebar.service.ts @@ -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 diff --git a/web/src/app/services/campaign.service.ts b/web/src/app/services/campaign.service.ts index 482fcad..2a49eb3 100644 --- a/web/src/app/services/campaign.service.ts +++ b/web/src/app/services/campaign.service.ts @@ -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 { + return this.http.get(`${this.apiUrl}/${id}/pdf-export`, { responseType: 'blob' }); + } + createCampaign(campaign: CampaignCreate): Observable { return this.http.post(this.apiUrl, campaign); } @@ -89,6 +94,11 @@ export class CampaignService { return this.http.get(`/api/arcs/${id}/deletion-impact`); } + /** Réordonne les arcs d'une campagne (glisser-déposer) : order = position. */ + reorderArcs(orderedIds: string[]): Observable { + return this.http.put('/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(`/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 { + return this.http.put('/api/chapters/reorder', { arcId, orderedIds }); + } + // ========== SCENE ========== getScenes(chapterId: string): Observable { const params = new HttpParams().set('chapterId', chapterId); @@ -144,6 +159,11 @@ export class CampaignService { return this.http.delete(`/api/scenes/${id}`); } + /** Réordonne (et déplace) les scènes d'un chapitre : order = position, chapterId = cible. */ + reorderScenes(chapterId: string, orderedIds: string[]): Observable { + return this.http.put('/api/scenes/reorder', { chapterId, orderedIds }); + } + search(q: string): Observable { const params = new HttpParams().set('q', q); return this.http.get(`${this.apiUrl}/search`, { params }); diff --git a/web/src/app/services/data-sync.service.ts b/web/src/app/services/data-sync.service.ts new file mode 100644 index 0000000..0ab3e50 --- /dev/null +++ b/web/src/app/services/data-sync.service.ts @@ -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(); + /** É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(reorder$: Observable, rollback: () => void): void { + reorder$.subscribe({ next: () => this.notify(), error: () => rollback() }); + } +} diff --git a/web/src/app/services/enemy.service.ts b/web/src/app/services/enemy.service.ts index d8757ab..47c8f21 100644 --- a/web/src/app/services/enemy.service.ts +++ b/web/src/app/services/enemy.service.ts @@ -32,6 +32,11 @@ export class EnemyService { return this.http.delete(`${this.apiUrl}/${id}`); } + /** Réordonne (et reclasse) les ennemis d'un dossier : order = position, folder = cible. */ + reorder(folder: string | null, orderedIds: string[]): Observable { + return this.http.put(`${this.apiUrl}/reorder`, { folder, orderedIds }); + } + /** Recherche par nom — alimente la recherche globale (Ctrl+K). */ search(q: string): Observable { return this.http.get(`${this.apiUrl}/search`, { params: { q } }); diff --git a/web/src/app/services/layout.service.ts b/web/src/app/services/layout.service.ts index 15aa619..be602bf 100644 --- a/web/src/app/services/layout.service.ts +++ b/web/src/app/services/layout.service.ts @@ -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; } /** diff --git a/web/src/app/services/lore.model.ts b/web/src/app/services/lore.model.ts index bae6704..7b47c1a 100644 --- a/web/src/app/services/lore.model.ts +++ b/web/src/app/services/lore.model.ts @@ -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; diff --git a/web/src/app/services/lore.service.ts b/web/src/app/services/lore.service.ts index 9598e5b..e1605b0 100644 --- a/web/src/app/services/lore.service.ts +++ b/web/src/app/services/lore.service.ts @@ -117,6 +117,15 @@ export class LoreService { return this.http.delete(`${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 { + return this.http.put(`${this.nodesUrl}/reorder`, { parentId, orderedIds }) + .pipe(tap(() => this.invalidate())); + } + getLoreNodeDeletionImpact(id: string): Observable { return this.http.get(`${this.nodesUrl}/${id}/deletion-impact`); } diff --git a/web/src/app/services/npc.service.ts b/web/src/app/services/npc.service.ts index 502737c..0f0c41b 100644 --- a/web/src/app/services/npc.service.ts +++ b/web/src/app/services/npc.service.ts @@ -37,6 +37,11 @@ export class NpcService { return this.http.delete(`${this.apiUrl}/${id}`); } + /** Réordonne (et reclasse) les PNJ d'un dossier : order = position, folder = dossier cible. */ + reorder(folder: string | null, orderedIds: string[]): Observable { + return this.http.put(`${this.apiUrl}/reorder`, { folder, orderedIds }); + } + /** Recherche par nom — alimente la recherche globale (Ctrl+K). */ search(q: string): Observable { return this.http.get(`${this.apiUrl}/search`, { params: { q } }); diff --git a/web/src/app/services/page.model.ts b/web/src/app/services/page.model.ts index 2b3927b..6a08116 100644 --- a/web/src/app/services/page.model.ts +++ b/web/src/app/services/page.model.ts @@ -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; /** * Pour chaque champ IMAGE du template, la liste ordonnee des IDs d'images diff --git a/web/src/app/services/page.service.ts b/web/src/app/services/page.service.ts index 7a576a6..4890fd6 100644 --- a/web/src/app/services/page.service.ts +++ b/web/src/app/services/page.service.ts @@ -59,6 +59,15 @@ export class PageService { return this.http.delete(`${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 { + return this.http.put(`${this.apiUrl}/reorder`, { nodeId, orderedIds }) + .pipe(tap(() => this.invalidate())); + } + search(q: string): Observable { const params = new HttpParams().set('q', q); return this.http.get(`${this.apiUrl}/search`, { params }); diff --git a/web/src/app/services/random-table.service.ts b/web/src/app/services/random-table.service.ts index 5303693..bd9488b 100644 --- a/web/src/app/services/random-table.service.ts +++ b/web/src/app/services/random-table.service.ts @@ -32,6 +32,11 @@ export class RandomTableService { return this.http.delete(`${this.apiUrl}/${id}`); } + /** Réordonne les tables aléatoires d'une campagne : order = position. */ + reorder(orderedIds: string[]): Observable { + return this.http.put(`${this.apiUrl}/reorder`, { orderedIds }); + } + /** Recherche par nom — alimente la recherche globale (Ctrl+K). */ search(q: string): Observable { return this.http.get(`${this.apiUrl}/search`, { params: { q } }); diff --git a/web/src/app/services/sidebar-reorder.service.ts b/web/src/app/services/sidebar-reorder.service.ts new file mode 100644 index 0000000..4694968 --- /dev/null +++ b/web/src/app/services/sidebar-reorder.service.ts @@ -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 { + 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))); + } + } +} diff --git a/web/src/app/sessions/session-reference-panel/session-reference-panel.component.ts b/web/src/app/sessions/session-reference-panel/session-reference-panel.component.ts index 6d34746..55b9d7f 100644 --- a/web/src/app/sessions/session-reference-panel/session-reference-panel.component.ts +++ b/web/src/app/sessions/session-reference-panel/session-reference-panel.component.ts @@ -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; diff --git a/web/src/app/shared/folder-grouping.util.ts b/web/src/app/shared/folder-grouping.util.ts new file mode 100644 index 0000000..d9c5262 --- /dev/null +++ b/web/src/app/shared/folder-grouping.util.ts @@ -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 { + 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(items: T[]): FolderGroup[] { + const sorted = [...items].sort(byOrder); + const byFolder = new Map(); + 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[] = [...byFolder.keys()] + .sort(byFolderName) + .map(folder => ({ folder, items: byFolder.get(folder)! })); + if (ungrouped.length) groups.push({ folder: '', items: ungrouped }); + return groups; +} diff --git a/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.html b/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.html index 6e24b2c..2cb6dea 100644 --- a/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.html +++ b/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.html @@ -33,75 +33,33 @@ }
} +
- @for (item of items; track item) { - - } -
- - - @if (level === 0 && item.sectionHeaderBefore) { -
- {{ item.sectionHeaderBefore }} -
- } -
-
- @if (!item.isAction && isExpandable(item)) { - + - @if (isExpanded(item.id) && hasChildren(item)) { -
- @for (child of item.children; track child) { - - } -
}
- +
@if (bottomPanel) {
diff --git a/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.scss b/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.scss index dd66cb7..74a7eca 100644 --- a/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.scss +++ b/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.scss @@ -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; } diff --git a/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.ts b/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.ts index b53bd8a..46e3b44 100644 --- a/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.ts +++ b/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.ts @@ -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(); /** 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(); + private _predicates = new Map boolean>(); + private _nodesById = new Map(); + 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 + private elementRef: ElementRef, + 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): 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); diff --git a/web/src/app/shared/secondary-sidebar/sidebar-tree-node.component.html b/web/src/app/shared/secondary-sidebar/sidebar-tree-node.component.html new file mode 100644 index 0000000..b9a42ff --- /dev/null +++ b/web/src/app/shared/secondary-sidebar/sidebar-tree-node.component.html @@ -0,0 +1,64 @@ +
+ @if (sidebar.dndEnabled() && item.dragKind) { + + + + } + @if (!item.isAction && sidebar.isExpandable(item)) { + + } + @if (item.isAction || !sidebar.isExpandable(item)) { + + } + + @if (item.createActions?.length) { + + @for (a of item.createActions; track a) { + + } + + } +
+ +@if (sidebar.isExpanded(item.id) && sidebar.hasChildren(item)) { + @if (sidebar.dndEnabled() && item.dropKinds) { +
+ @for (child of item.children; track child.id) { + @if (sidebar.dndEnabled() && child.dragKind) { + + } @else { + + } + } +
+ } @else { +
+ @for (child of item.children; track child.id) { + + } +
+ } +} diff --git a/web/src/app/shared/secondary-sidebar/sidebar-tree-node.component.scss b/web/src/app/shared/secondary-sidebar/sidebar-tree-node.component.scss new file mode 100644 index 0000000..8a33ef9 --- /dev/null +++ b/web/src/app/shared/secondary-sidebar/sidebar-tree-node.component.scss @@ -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; } diff --git a/web/src/app/shared/secondary-sidebar/sidebar-tree-node.component.ts b/web/src/app/shared/secondary-sidebar/sidebar-tree-node.component.ts new file mode 100644 index 0000000..253fa23 --- /dev/null +++ b/web/src/app/shared/secondary-sidebar/sidebar-tree-node.component.ts @@ -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 + * `` 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); +} diff --git a/web/src/assets/i18n/en.json b/web/src/assets/i18n/en.json index f09d8d5..bd84b18 100644 --- a/web/src/assets/i18n/en.json +++ b/web/src/assets/i18n/en.json @@ -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", diff --git a/web/src/assets/i18n/fr.json b/web/src/assets/i18n/fr.json index 6378fb5..65521bb 100644 --- a/web/src/assets/i18n/fr.json +++ b/web/src/assets/i18n/fr.json @@ -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", diff --git a/web/src/assets/i18n/fragments/campaign-tree.en.json b/web/src/assets/i18n/fragments/campaign-tree.en.json index 4b640b2..fe670ed 100644 --- a/web/src/assets/i18n/fragments/campaign-tree.en.json +++ b/web/src/assets/i18n/fragments/campaign-tree.en.json @@ -1,6 +1,7 @@ { "campaignTree": { "npcs": "NPCs", + "unclassified": "Unfiled", "newNpc": "New NPC", "newScene": "New scene", "newQuest": "New quest", diff --git a/web/src/assets/i18n/fragments/campaign-tree.fr.json b/web/src/assets/i18n/fragments/campaign-tree.fr.json index cff4af3..8d64757 100644 --- a/web/src/assets/i18n/fragments/campaign-tree.fr.json +++ b/web/src/assets/i18n/fragments/campaign-tree.fr.json @@ -1,6 +1,7 @@ { "campaignTree": { "npcs": "PNJ", + "unclassified": "Sans dossier", "newNpc": "Nouveau PNJ", "newScene": "Nouvelle scène", "newQuest": "Nouvelle quête", diff --git a/web/src/assets/i18n/fragments/campaigns-top.en.json b/web/src/assets/i18n/fragments/campaigns-top.en.json index 0963dc4..2c4bc52 100644 --- a/web/src/assets/i18n/fragments/campaigns-top.en.json +++ b/web/src/assets/i18n/fragments/campaigns-top.en.json @@ -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.", diff --git a/web/src/assets/i18n/fragments/campaigns-top.fr.json b/web/src/assets/i18n/fragments/campaigns-top.fr.json index b5b05d4..ad91717 100644 --- a/web/src/assets/i18n/fragments/campaigns-top.fr.json +++ b/web/src/assets/i18n/fragments/campaigns-top.fr.json @@ -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.", diff --git a/web/src/assets/i18n/fragments/shared-chrome.en.json b/web/src/assets/i18n/fragments/shared-chrome.en.json index a6c6098..18c0927 100644 --- a/web/src/assets/i18n/fragments/shared-chrome.en.json +++ b/web/src/assets/i18n/fragments/shared-chrome.en.json @@ -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" diff --git a/web/src/assets/i18n/fragments/shared-chrome.fr.json b/web/src/assets/i18n/fragments/shared-chrome.fr.json index 99d0c6d..aee7a73 100644 --- a/web/src/assets/i18n/fragments/shared-chrome.fr.json +++ b/web/src/assets/i18n/fragments/shared-chrome.fr.json @@ -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" diff --git a/web/src/styles.scss b/web/src/styles.scss index e40d74c..cc1296e 100644 --- a/web/src/styles.scss +++ b/web/src/styles.scss @@ -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 : 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; }