From 9bd66613b6a57117469ae3e83b80158f468dcced Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Thu, 25 Jun 2026 15:21:25 +0200 Subject: [PATCH] =?UTF-8?q?Ajout=20de=20drag'n=20drop=20l=C3=A0=20ou=20c'e?= =?UTF-8?q?st=20possible=20;=20ajout=20de=20l'import=20des=20ennemis=20dep?= =?UTF-8?q?uis=20foundryVTT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../campaigncontext/EnemyService.java | 49 ++++++++++++++ .../domain/campaigncontext/Enemy.java | 8 +++ .../persistence/entity/EnemyJpaEntity.java | 4 ++ .../postgres/PostgresEnemyRepository.java | 2 + .../transfer/ExportService.java | 2 +- .../transfer/ImportService.java | 1 + .../transfer/dto/ContentExport.java | 1 + .../transfer/foundry/FoundryBundle.java | 9 ++- .../foundry/FoundryExportService.java | 22 ++++++- .../FoundryMonsterImportController.java | 41 ++++++++++++ .../db/migration/V3__enemy_foundry_ref.sql | 9 +++ .../EnemyServiceMonsterImportTest.java | 64 +++++++++++++++++++ .../foundry/FoundryExportServiceTest.java | 25 ++++++++ .../campaign-import.component.html | 2 +- .../campaign-import.component.scss | 3 + .../campaign-import.component.ts | 8 ++- .../enemy-list/enemy-list.component.html | 6 +- .../enemy-list/enemy-list.component.scss | 3 + .../enemy/enemy-list/enemy-list.component.ts | 48 +++++++++++++- .../scene-edit/scene-edit.component.html | 8 ++- .../scene-edit/scene-edit.component.scss | 10 ++- .../scene/scene-edit/scene-edit.component.ts | 25 ++++++-- web/src/app/services/enemy.model.ts | 2 + web/src/app/services/enemy.service.ts | 14 ++++ web/src/app/settings/settings.component.html | 2 +- web/src/app/settings/settings.component.scss | 7 ++ web/src/app/settings/settings.component.ts | 13 +++- web/src/app/shared/file-drop.directive.ts | 55 ++++++++++++++++ .../image-uploader.component.html | 8 ++- .../image-uploader.component.scss | 1 + web/src/assets/i18n/en.json | 2 + web/src/assets/i18n/fr.json | 2 + .../i18n/fragments/chars-npcs-enemies.en.json | 2 + .../i18n/fragments/chars-npcs-enemies.fr.json | 2 + 34 files changed, 439 insertions(+), 21 deletions(-) create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/FoundryMonsterImportController.java create mode 100644 core/src/main/resources/db/migration/V3__enemy_foundry_ref.sql create mode 100644 core/src/test/java/com/loremind/application/campaigncontext/EnemyServiceMonsterImportTest.java create mode 100644 web/src/app/shared/file-drop.directive.ts 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 67e9b72..3a0b4aa 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/EnemyService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/EnemyService.java @@ -81,6 +81,55 @@ public class EnemyService { enemyRepository.deleteById(id); } + // --- Import de monstres depuis un compendium Foundry ------------------- + + /** Un monstre du catalogue Foundry : nom + référence (UUID de compendium). */ + public record MonsterImport(String name, String foundryRef) {} + + public record MonsterImportResult(int created, int updated) {} + + /** + * Importe (upsert) des monstres Foundry dans le bestiaire d'une campagne. + * Dédup par {@code foundryRef} : un monstre déjà importé est mis à jour (nom), + * jamais dupliqué. Fiche minimale (nom + référence) ; les stats restent côté + * Foundry et sont ré-instanciées à l'export. + */ + public MonsterImportResult importFoundryMonsters(String campaignId, List monsters) { + List existing = enemyRepository.findByCampaignId(campaignId); + Map byRef = new HashMap<>(); + for (Enemy e : existing) { + if (e.getFoundryRef() != null) byRef.put(e.getFoundryRef(), e); + } + int order = existing.stream().mapToInt(Enemy::getOrder).max().orElse(-1) + 1; + + int created = 0, updated = 0; + for (MonsterImport m : monsters) { + if (m.foundryRef() == null || m.foundryRef().isBlank() + || m.name() == null || m.name().isBlank()) { + continue; + } + Enemy ex = byRef.get(m.foundryRef()); + if (ex != null) { + ex.setName(m.name()); + enemyRepository.save(ex); + updated++; + } else { + Enemy saved = enemyRepository.save(Enemy.builder() + .name(m.name()) + .foundryRef(m.foundryRef()) + .campaignId(campaignId) + .order(order++) + .values(new HashMap<>()) + .imageValues(new HashMap<>()) + .keyValueValues(new HashMap<>()) + .build()); + byRef.put(m.foundryRef(), saved); + created++; + } + } + return new MonsterImportResult(created, updated); + } + public List searchEnemies(String query) { if (query == null || query.isBlank()) return List.of(); return enemyRepository.searchByName(query.trim()); diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Enemy.java b/core/src/main/java/com/loremind/domain/campaigncontext/Enemy.java index fedfc9b..9d16a71 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/Enemy.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/Enemy.java @@ -47,6 +47,14 @@ public class Enemy { /** Référence vers la Campaign parente (cross-aggregate via ID). */ private String campaignId; + /** + * Référence vers l'acteur Foundry d'origine (UUID de compendium, ex. + * {@code Compendium.nimble.monsters.Actor.abc123}). Renseigné quand l'ennemi + * a été importé depuis un compendium Foundry ; permet, à l'export, de poser + * un token du VRAI acteur (stats natives). Null pour un ennemi fait main. + */ + private String foundryRef; + /** Ordre d'affichage dans la liste. */ private int order; diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/EnemyJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/EnemyJpaEntity.java index 840e98c..6acf78e 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/EnemyJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/EnemyJpaEntity.java @@ -63,6 +63,10 @@ public class EnemyJpaEntity { @Column(name = "campaign_id", nullable = false) private Long campaignId; + /** UUID de l'acteur de compendium Foundry d'origine (import bestiaire). Nullable. */ + @Column(name = "foundry_ref", length = 512) + private String foundryRef; + @Column(name = "\"order\"", nullable = false) private int order; diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresEnemyRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresEnemyRepository.java index 0d59c86..e9ffc8e 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresEnemyRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresEnemyRepository.java @@ -61,6 +61,7 @@ public class PostgresEnemyRepository implements EnemyRepository { .imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>()) .keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>()) .campaignId(e.getCampaignId().toString()) + .foundryRef(e.getFoundryRef()) .order(e.getOrder()) .createdAt(e.getCreatedAt()) .updatedAt(e.getUpdatedAt()) @@ -79,6 +80,7 @@ public class PostgresEnemyRepository implements EnemyRepository { .imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>()) .keyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>()) .campaignId(Long.parseLong(n.getCampaignId())) + .foundryRef(n.getFoundryRef()) .order(n.getOrder()) .createdAt(n.getCreatedAt()) .updatedAt(n.getUpdatedAt()) diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/ExportService.java b/core/src/main/java/com/loremind/infrastructure/transfer/ExportService.java index 45401f3..87cc3f8 100644 --- a/core/src/main/java/com/loremind/infrastructure/transfer/ExportService.java +++ b/core/src/main/java/com/loremind/infrastructure/transfer/ExportService.java @@ -364,7 +364,7 @@ public class ExportService { private ContentExport.EnemyDto toEnemyDto(EnemyJpaEntity e) { return new ContentExport.EnemyDto(e.getId(), e.getName(), e.getLevel(), e.getFolder(), e.getPortraitImageId(), e.getHeaderImageId(), e.getValues(), e.getImageValues(), - e.getKeyValueValues(), e.getCampaignId(), e.getOrder()); + e.getKeyValueValues(), e.getCampaignId(), e.getFoundryRef(), e.getOrder()); } private ContentExport.ItemCatalogDto toItemCatalogDto(ItemCatalogJpaEntity e) { diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java b/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java index 21ce968..af68045 100644 --- a/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java +++ b/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java @@ -335,6 +335,7 @@ public class ImportService { e.setImageValues(d.imageValues()); e.setKeyValueValues(d.keyValueValues()); e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId())); + e.setFoundryRef(d.foundryRef()); // ref externe Foundry : conservee telle quelle e.setOrder(d.order()); enemyMap.put(d.id(), enemyRepo.save(e).getId()); } diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/dto/ContentExport.java b/core/src/main/java/com/loremind/infrastructure/transfer/dto/ContentExport.java index e5a22a2..aee8ffc 100644 --- a/core/src/main/java/com/loremind/infrastructure/transfer/dto/ContentExport.java +++ b/core/src/main/java/com/loremind/infrastructure/transfer/dto/ContentExport.java @@ -209,6 +209,7 @@ public record ContentExport( Map> imageValues, Map> keyValueValues, Long campaignId, + String foundryRef, int order ) {} diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryBundle.java b/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryBundle.java index 9c49649..eaef788 100644 --- a/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryBundle.java +++ b/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryBundle.java @@ -35,6 +35,7 @@ public final class FoundryBundle { List scenes, List npcs, List enemies, + List randomTables, List assets ) {} @@ -75,7 +76,8 @@ public final class FoundryBundle { public record Persona( String id, String name, String folder, int order, - String portraitAssetId, String headerAssetId, String level, List fields + String portraitAssetId, String headerAssetId, String level, + String foundryRef, List fields ) {} /** Champ de fiche resolu : {type, label} + selon le type value | entries | assetIds. */ @@ -83,5 +85,10 @@ public final class FoundryBundle { public record Entry(String label, String value) {} + /** Table aléatoire -> RollTable Foundry (formule + entrées avec intervalles). */ + public record RandomTable(String id, String name, String description, String diceFormula, List entries) {} + + public record RandomTableEntry(int minRoll, int maxRoll, String label, String detail) {} + public record Asset(String id, String kind, String path, String filename, String mime, long sizeBytes) {} } 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 f5534c2..fbb920f 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 @@ -43,6 +43,7 @@ public class FoundryExportService { private final SceneJpaRepository sceneRepo; private final NpcJpaRepository npcRepo; private final EnemyJpaRepository enemyRepo; + private final RandomTableJpaRepository randomTableRepo; private final GameSystemJpaRepository gameSystemRepo; private final ImageJpaRepository imageRepo; private final StoredFileJpaRepository storedFileRepo; @@ -57,6 +58,7 @@ public class FoundryExportService { SceneJpaRepository sceneRepo, NpcJpaRepository npcRepo, EnemyJpaRepository enemyRepo, + RandomTableJpaRepository randomTableRepo, GameSystemJpaRepository gameSystemRepo, ImageJpaRepository imageRepo, StoredFileJpaRepository storedFileRepo, @@ -70,6 +72,7 @@ public class FoundryExportService { this.sceneRepo = sceneRepo; this.npcRepo = npcRepo; this.enemyRepo = enemyRepo; + this.randomTableRepo = randomTableRepo; this.gameSystemRepo = gameSystemRepo; this.imageRepo = imageRepo; this.storedFileRepo = storedFileRepo; @@ -130,7 +133,7 @@ public class FoundryExportService { for (NpcJpaEntity n : npcRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) { npcs.add(new FoundryBundle.Persona( str(n.getId()), n.getName(), n.getFolder(), n.getOrder(), - assets.image(n.getPortraitImageId()), assets.image(n.getHeaderImageId()), null, + assets.image(n.getPortraitImageId()), assets.image(n.getHeaderImageId()), null, null, fields(npcTemplate, n.getValues(), n.getKeyValueValues(), n.getImageValues(), assets))); } @@ -139,14 +142,28 @@ public class FoundryExportService { enemies.add(new FoundryBundle.Persona( str(e.getId()), e.getName(), e.getFolder(), e.getOrder(), assets.image(e.getPortraitImageId()), assets.image(e.getHeaderImageId()), e.getLevel(), + e.getFoundryRef(), fields(enemyTemplate, e.getValues(), e.getKeyValueValues(), e.getImageValues(), assets))); } + List randomTables = new ArrayList<>(); + for (RandomTableJpaEntity t : randomTableRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) { + List entries = new ArrayList<>(); + if (t.getEntries() != null) { + for (RandomTableEntryJpaEntity en : t.getEntries()) { + entries.add(new FoundryBundle.RandomTableEntry( + en.getMinRoll(), en.getMaxRoll(), en.getLabel(), en.getDetail())); + } + } + randomTables.add(new FoundryBundle.RandomTable( + str(t.getId()), t.getName(), t.getDescription(), t.getDiceFormula(), entries)); + } + FoundryBundle.Campaign campaignNode = new FoundryBundle.Campaign( str(campaign.getId()), campaign.getName(), campaign.getDescription(), campaign.getGameSystemId()); FoundryBundle.Data data = new FoundryBundle.Data( - FORMAT_VERSION, campaignNode, arcs, quests, scenes, npcs, enemies, assets.assets()); + FORMAT_VERSION, campaignNode, arcs, quests, scenes, npcs, enemies, randomTables, assets.assets()); Map counts = new LinkedHashMap<>(); counts.put("arcs", arcs.size()); @@ -154,6 +171,7 @@ public class FoundryExportService { counts.put("scenes", scenes.size()); counts.put("npcs", npcs.size()); counts.put("enemies", enemies.size()); + counts.put("randomTables", randomTables.size()); counts.put("assets", assets.assets().size()); FoundryBundle.Manifest manifest = new FoundryBundle.Manifest( diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/FoundryMonsterImportController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/FoundryMonsterImportController.java new file mode 100644 index 0000000..06ace26 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/FoundryMonsterImportController.java @@ -0,0 +1,41 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.application.campaigncontext.EnemyService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * Import dans le bestiaire d'une campagne d'un catalogue de monstres exporté depuis + * Foundry (compendiums). On ne stocke que nom + référence (UUID de compendium) : + * les stats restent côté Foundry et sont ré-instanciées à l'export. + * + * {@code POST /api/campaigns/{campaignId}/import-foundry-monsters} + */ +@RestController +@RequestMapping("/api/campaigns/{campaignId}") +public class FoundryMonsterImportController { + + private final EnemyService enemyService; + + public FoundryMonsterImportController(EnemyService enemyService) { + this.enemyService = enemyService; + } + + /** Format du catalogue produit par le module Foundry. */ + public record MonsterCatalog(String system, List monsters) {} + + public record MonsterEntry(String name, String uuid, String img) {} + + @PostMapping("/import-foundry-monsters") + public ResponseEntity importMonsters( + @PathVariable String campaignId, + @RequestBody MonsterCatalog catalog) { + List monsters = (catalog.monsters() == null ? List.of() : catalog.monsters()) + .stream() + .map(m -> new EnemyService.MonsterImport(m.name(), m.uuid())) + .toList(); + return ResponseEntity.ok(enemyService.importFoundryMonsters(campaignId, monsters)); + } +} diff --git a/core/src/main/resources/db/migration/V3__enemy_foundry_ref.sql b/core/src/main/resources/db/migration/V3__enemy_foundry_ref.sql new file mode 100644 index 0000000..78d3af8 --- /dev/null +++ b/core/src/main/resources/db/migration/V3__enemy_foundry_ref.sql @@ -0,0 +1,9 @@ +-- ============================================================================ +-- V3 : reference Foundry sur les ennemis (import depuis un compendium). +-- ============================================================================ +-- Un ennemi importe d'un compendium Foundry porte l'UUID de l'acteur source +-- (ex. Compendium.nimble.monsters.Actor.abc123). Permet, a l'export Foundry, de +-- poser un token du VRAI acteur (stats natives), sans recopier les stats. +-- Nullable : un ennemi fait main n'a pas de reference. + +alter table enemies add column foundry_ref varchar(512); diff --git a/core/src/test/java/com/loremind/application/campaigncontext/EnemyServiceMonsterImportTest.java b/core/src/test/java/com/loremind/application/campaigncontext/EnemyServiceMonsterImportTest.java new file mode 100644 index 0000000..9b27c24 --- /dev/null +++ b/core/src/test/java/com/loremind/application/campaigncontext/EnemyServiceMonsterImportTest.java @@ -0,0 +1,64 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.infrastructure.persistence.entity.CampaignJpaEntity; +import com.loremind.infrastructure.persistence.jpa.CampaignJpaRepository; +import com.loremind.infrastructure.persistence.jpa.EnemyJpaRepository; +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.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Import de monstres Foundry dans le bestiaire : upsert (dédup par foundryRef). + */ +@SpringBootTest +@Transactional +class EnemyServiceMonsterImportTest { + + @Autowired private EnemyService enemyService; + @Autowired private CampaignJpaRepository campaignRepo; + @Autowired private EnemyJpaRepository enemyRepo; + + @Test + void importFoundryMonsters_createsThenUpsertsByFoundryRef() { + Long cid = campaignRepo.save( + CampaignJpaEntity.builder().name("Camp").arcsCount(0).build()).getId(); + + var r1 = enemyService.importFoundryMonsters(String.valueOf(cid), List.of( + new EnemyService.MonsterImport("Goblin", "Compendium.nimble.monsters.Actor.g1"), + new EnemyService.MonsterImport("Orc", "Compendium.nimble.monsters.Actor.o1"))); + assertEquals(2, r1.created()); + assertEquals(0, r1.updated()); + assertEquals(2, enemyRepo.findByCampaignIdOrderByOrderAsc(cid).size()); + + // Réimport : g1 déjà connu (renommé) + un nouveau (k1). Pas de doublon pour g1. + var r2 = enemyService.importFoundryMonsters(String.valueOf(cid), List.of( + new EnemyService.MonsterImport("Goblin Boss", "Compendium.nimble.monsters.Actor.g1"), + new EnemyService.MonsterImport("Kobold", "Compendium.nimble.monsters.Actor.k1"))); + assertEquals(1, r2.created()); + assertEquals(1, r2.updated()); + + var all = enemyRepo.findByCampaignIdOrderByOrderAsc(cid); + assertEquals(3, all.size()); + assertTrue(all.stream().anyMatch(e -> + "Goblin Boss".equals(e.getName()) + && "Compendium.nimble.monsters.Actor.g1".equals(e.getFoundryRef()))); + } + + @Test + void importFoundryMonsters_ignoresBlankRefOrName() { + Long cid = campaignRepo.save( + CampaignJpaEntity.builder().name("Camp2").arcsCount(0).build()).getId(); + + var r = enemyService.importFoundryMonsters(String.valueOf(cid), List.of( + new EnemyService.MonsterImport("", "Compendium.x.Actor.a"), + new EnemyService.MonsterImport("SansRef", " "), + new EnemyService.MonsterImport("Valide", "Compendium.x.Actor.b"))); + assertEquals(1, r.created()); + assertEquals(1, enemyRepo.findByCampaignIdOrderByOrderAsc(cid).size()); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/transfer/foundry/FoundryExportServiceTest.java b/core/src/test/java/com/loremind/infrastructure/transfer/foundry/FoundryExportServiceTest.java index 1af6238..44c5392 100644 --- a/core/src/test/java/com/loremind/infrastructure/transfer/foundry/FoundryExportServiceTest.java +++ b/core/src/test/java/com/loremind/infrastructure/transfer/foundry/FoundryExportServiceTest.java @@ -32,6 +32,7 @@ class FoundryExportServiceTest { @Autowired private GameSystemJpaRepository gameSystemRepo; @Autowired private ImageJpaRepository imageRepo; @Autowired private StoredFileJpaRepository fileRepo; + @Autowired private RandomTableJpaRepository tableRepo; @Test void buildBundle_assemblesFullCampaign_withBattlemapAssetsAndResolvedNpcFields() { @@ -76,6 +77,19 @@ class FoundryExportServiceTest { .keyValueValues(Map.of("Caracs", Map.of("FOR", "16"))) .build()); + // Table aléatoire (1d6) avec 2 entrées -> RollTable Foundry. + RandomTableJpaEntity table = RandomTableJpaEntity.builder() + .campaignId(camp.getId()).name("Rencontres").description("Sur la route").diceFormula("1d6").order(0) + .build(); + RandomTableEntryJpaEntity e1 = new RandomTableEntryJpaEntity(); + e1.setMinRoll(1); e1.setMaxRoll(3); e1.setLabel("Gobelins"); e1.setDetail("3d4"); e1.setPosition(0); + e1.setRandomTable(table); + RandomTableEntryJpaEntity e2 = new RandomTableEntryJpaEntity(); + e2.setMinRoll(4); e2.setMaxRoll(6); e2.setLabel("Rien"); e2.setPosition(1); + e2.setRandomTable(table); + table.setEntries(List.of(e1, e2)); + tableRepo.save(table); + FoundryExportService.BuiltBundle bundle = service.buildBundle(String.valueOf(camp.getId()), "2026-06-25T00:00:00Z"); FoundryBundle.Data data = bundle.data(); @@ -119,10 +133,21 @@ class FoundryExportServiceTest { assertEquals("FOR", caracs.entries().get(0).label()); assertEquals("16", caracs.entries().get(0).value()); + // Table aléatoire -> RollTable (formule + entrées avec intervalles). + assertEquals(1, data.randomTables().size()); + FoundryBundle.RandomTable rt = data.randomTables().get(0); + assertEquals("Rencontres", rt.name()); + assertEquals("1d6", rt.diceFormula()); + assertEquals(2, rt.entries().size()); + assertEquals("Gobelins", rt.entries().get(0).label()); + assertEquals(1, rt.entries().get(0).minRoll()); + assertEquals(3, rt.entries().get(0).maxRoll()); + // Manifest assertEquals("1.0", bundle.manifest().formatVersion()); assertEquals("plain", bundle.manifest().contentFormat()); assertEquals(1, bundle.manifest().counts().get("scenes")); + assertEquals(1, bundle.manifest().counts().get("randomTables")); assertEquals(3, bundle.manifest().counts().get("assets")); } diff --git a/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.html b/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.html index 7d372bc..f1ad476 100644 --- a/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.html +++ b/web/src/app/campaigns/campaign/campaign-import/campaign-import.component.html @@ -11,7 +11,7 @@ @if (!reviewing) { -
+
+ diff --git a/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.scss b/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.scss index 501899e..d671e29 100644 --- a/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.scss +++ b/web/src/app/campaigns/enemy/enemy-list/enemy-list.component.scss @@ -2,6 +2,9 @@ .sbl-toolbar { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; + border: 1px dashed transparent; border-radius: 6px; + transition: border-color 0.15s, background 0.15s; + &.drag-over { border-color: #667eea; background: rgba(102,126,234,0.12); } .spacer { flex: 1; } button { display: inline-flex; align-items: center; gap: 0.35rem; 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 d9d6a05..a7f8db8 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,12 +1,13 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { LucideAngularModule, ArrowLeft, Plus, Trash2, Skull, Folder } from 'lucide-angular'; +import { LucideAngularModule, ArrowLeft, Plus, Trash2, Skull, Folder, Upload } from 'lucide-angular'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { EnemyService } from '../../../services/enemy.service'; import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; 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 { @@ -20,7 +21,7 @@ interface FolderGroup { */ @Component({ selector: 'app-enemy-list', - imports: [LucideAngularModule, TranslatePipe], + imports: [LucideAngularModule, TranslatePipe, FileDropDirective], templateUrl: './enemy-list.component.html', styleUrls: ['./enemy-list.component.scss'] }) @@ -30,6 +31,10 @@ export class EnemyListComponent implements OnInit { readonly Trash2 = Trash2; readonly Skull = Skull; readonly Folder = Folder; + readonly Upload = Upload; + + /** Import de monstres Foundry en cours (anti double-clic). */ + importing = false; campaignId = ''; /** Groupes triés par nom de dossier ; les non-classés en dernier (folder = ''). */ @@ -86,6 +91,45 @@ export class EnemyListComponent implements OnInit { this.router.navigate(['/campaigns', this.campaignId, 'enemies', 'create']); } + /** + * Importe un catalogue de monstres Foundry (.json exporté par le module) dans + * le bestiaire. Upsert côté backend (dédup par référence). + */ + importMonsters(): void { + if (this.importing) return; + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json,application/json'; + input.addEventListener('change', () => { + const file = input.files?.[0]; + if (file) this.importMonsterFile(file); + }, { once: true }); + input.click(); + } + + /** Drop d'un catalogue de monstres (.json) sur la barre d'outils. */ + onMonstersDropped(files: File[]): void { + if (files[0]) this.importMonsterFile(files[0]); + } + + private importMonsterFile(file: File): void { + if (this.importing) return; + file.text().then(txt => { + let catalog: unknown; + try { + catalog = JSON.parse(txt); + } catch { + console.error('Catalogue de monstres illisible (JSON invalide)'); + return; + } + this.importing = true; + this.service.importFoundryMonsters(this.campaignId, catalog).subscribe({ + next: () => { this.importing = false; this.load(); }, + error: (err) => { this.importing = false; console.error('Échec import monstres', err); } + }); + }); + } + open(e: Enemy): void { this.router.navigate(['/campaigns', this.campaignId, 'enemies', e.id]); } diff --git a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html index ae602ef..70d36b8 100644 --- a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html +++ b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html @@ -43,7 +43,9 @@ {{ 'sceneEdit.battlemapHint' | translate }} -
+
{{ 'sceneEdit.battlemapMedia' | translate }} @if (battlemapMediaFileId) { {{ battlemapMediaName || ('sceneEdit.battlemapAttached' | translate) }} @@ -58,7 +60,9 @@ }
-
+
{{ 'sceneEdit.battlemapData' | translate }} @if (battlemapDataFileId) { {{ battlemapDataName || ('sceneEdit.battlemapAttached' | translate) }} diff --git a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.scss b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.scss index dd5decd..d1f70c2 100644 --- a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.scss +++ b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.scss @@ -8,7 +8,15 @@ display: flex; align-items: center; gap: 0.75rem; - padding: 0.5rem 0; + padding: 0.5rem 0.5rem; + border: 1px dashed transparent; + border-radius: 6px; + transition: border-color 0.15s, background 0.15s; + + &.drag-over { + border-color: #6c63ff; + background: #1f1f3a; + } .battlemap-slot-label { min-width: 160px; 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 3ca9989..1cd300b 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 @@ -26,6 +26,7 @@ import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-dr import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component'; import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component'; import { RoomsEditorComponent } from '../../../shared/rooms-editor/rooms-editor.component'; +import { FileDropDirective } from '../../../shared/file-drop.directive'; import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons'; import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service'; @@ -35,7 +36,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia */ @Component({ selector: 'app-scene-edit', - imports: [ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, EnemyLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent, RoomsEditorComponent, TranslatePipe], + imports: [ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, EnemyLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent, RoomsEditorComponent, FileDropDirective, TranslatePipe], templateUrl: './scene-edit.component.html', styleUrls: ['./scene-edit.component.scss'] }) @@ -282,7 +283,15 @@ export class SceneEditComponent implements OnInit, OnDestroy { onBattlemapMediaSelected(event: Event): void { const input = event.target as HTMLInputElement; const file = input.files?.[0]; - if (!file) return; + if (file) this.uploadBattlemapMedia(file); + input.value = ''; + } + + onBattlemapMediaDropped(files: File[]): void { + if (files[0]) this.uploadBattlemapMedia(files[0]); + } + + private uploadBattlemapMedia(file: File): void { this.battlemapUploadingMedia = true; this.storedFileService.upload(file).subscribe({ next: f => { @@ -292,13 +301,20 @@ export class SceneEditComponent implements OnInit, OnDestroy { }, error: () => { this.battlemapUploadingMedia = false; } }); - input.value = ''; } onBattlemapDataSelected(event: Event): void { const input = event.target as HTMLInputElement; const file = input.files?.[0]; - if (!file) return; + if (file) this.uploadBattlemapData(file); + input.value = ''; + } + + onBattlemapDataDropped(files: File[]): void { + if (files[0]) this.uploadBattlemapData(files[0]); + } + + private uploadBattlemapData(file: File): void { this.battlemapUploadingData = true; this.storedFileService.upload(file).subscribe({ next: f => { @@ -308,7 +324,6 @@ export class SceneEditComponent implements OnInit, OnDestroy { }, error: () => { this.battlemapUploadingData = false; } }); - input.value = ''; } removeBattlemapMedia(): void { diff --git a/web/src/app/services/enemy.model.ts b/web/src/app/services/enemy.model.ts index c1b198a..51d62cb 100644 --- a/web/src/app/services/enemy.model.ts +++ b/web/src/app/services/enemy.model.ts @@ -17,6 +17,8 @@ export interface Enemy { keyValueValues?: Record>; campaignId: string; order?: number; + /** UUID de l'acteur de compendium Foundry d'origine (monstre importé). Null sinon. */ + foundryRef?: string | null; } export interface EnemyCreate { diff --git a/web/src/app/services/enemy.service.ts b/web/src/app/services/enemy.service.ts index f7b330a..d8757ab 100644 --- a/web/src/app/services/enemy.service.ts +++ b/web/src/app/services/enemy.service.ts @@ -36,4 +36,18 @@ export class EnemyService { search(q: string): Observable { return this.http.get(`${this.apiUrl}/search`, { params: { q } }); } + + /** + * Importe un catalogue de monstres Foundry (exporté par le module) dans le + * bestiaire de la campagne. Upsert par référence côté backend. + */ + importFoundryMonsters(campaignId: string, catalog: unknown): Observable { + return this.http.post( + `/api/campaigns/${campaignId}/import-foundry-monsters`, catalog); + } +} + +export interface MonsterImportResult { + created: number; + updated: number; } diff --git a/web/src/app/settings/settings.component.html b/web/src/app/settings/settings.component.html index 6c8907b..ae6de6f 100644 --- a/web/src/app/settings/settings.component.html +++ b/web/src/app/settings/settings.component.html @@ -376,7 +376,7 @@

{{ 'settings.data.title' | translate }}

-
+
diff --git a/web/src/app/settings/settings.component.scss b/web/src/app/settings/settings.component.scss index 2d5529f..e3624b9 100644 --- a/web/src/app/settings/settings.component.scss +++ b/web/src/app/settings/settings.component.scss @@ -290,6 +290,13 @@ display: flex; justify-content: flex-end; margin-top: 24px; + gap: 0.5rem; + border: 1px dashed transparent; + border-radius: 8px; + padding: 0.4rem; + transition: border-color 0.15s, background 0.15s; + + &.drag-over { border-color: #667eea; background: rgba(102,126,234,0.1); } } .alert { diff --git a/web/src/app/settings/settings.component.ts b/web/src/app/settings/settings.component.ts index 45ce1c5..855668e 100644 --- a/web/src/app/settings/settings.component.ts +++ b/web/src/app/settings/settings.component.ts @@ -9,6 +9,7 @@ import { LayoutService } from '../services/layout.service'; import { UpdatesSectionComponent } from './updates-section/updates-section.component'; import { OllamaModelManagerComponent } from './ollama-model-manager/ollama-model-manager.component'; import { LanguageSwitcherComponent } from '../shared/language-switcher/language-switcher.component'; +import { FileDropDirective } from '../shared/file-drop.directive'; /** * Ecran de parametrage du LLM utilise par le Brain. @@ -26,7 +27,7 @@ import { LanguageSwitcherComponent } from '../shared/language-switcher/language- */ @Component({ selector: 'app-settings', - imports: [CommonModule, FormsModule, LucideAngularModule, TranslatePipe, UpdatesSectionComponent, OllamaModelManagerComponent, LanguageSwitcherComponent], + imports: [CommonModule, FormsModule, LucideAngularModule, TranslatePipe, UpdatesSectionComponent, OllamaModelManagerComponent, LanguageSwitcherComponent, FileDropDirective], templateUrl: './settings.component.html', styleUrls: ['./settings.component.scss'] }) @@ -366,8 +367,16 @@ export class SettingsComponent implements OnInit { const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''; // permet de re-sélectionner le même fichier - if (!file) return; + if (file) this.importBackupFile(file); + } + /** Drop d'un zip de sauvegarde sur la zone d'actions. */ + onBackupDropped(files: File[]): void { + if (files[0]) this.importBackupFile(files[0]); + } + + private importBackupFile(file: File): void { + if (this.importing) return; if (!confirm(this.translate.instant('settings.data.importConfirm'))) return; this.importing = true; diff --git a/web/src/app/shared/file-drop.directive.ts b/web/src/app/shared/file-drop.directive.ts new file mode 100644 index 0000000..64f175d --- /dev/null +++ b/web/src/app/shared/file-drop.directive.ts @@ -0,0 +1,55 @@ +import { Directive, EventEmitter, HostBinding, HostListener, Input, Output } from '@angular/core'; + +/** + * Directive de drag & drop de fichiers réutilisable. + * + * Pose la classe `.drag-over` sur l'élément hôte pendant le survol (à styler côté + * composant) et émet les fichiers déposés. + * + * Usage : + *
+ *
+ */ +@Directive({ + selector: '[appFileDrop]', + standalone: true +}) +export class FileDropDirective { + /** Autoriser plusieurs fichiers (sinon seul le premier est émis). */ + @Input() multiple = false; + + /** Désactive le drop (ex: pendant un upload en cours). */ + @Input() dropDisabled = false; + + @Output() filesDropped = new EventEmitter(); + + @HostBinding('class.drag-over') isOver = false; + + @HostListener('dragover', ['$event']) + onDragOver(event: DragEvent): void { + if (this.dropDisabled) return; + event.preventDefault(); + event.stopPropagation(); + if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'; + this.isOver = true; + } + + @HostListener('dragleave', ['$event']) + onDragLeave(event: DragEvent): void { + event.preventDefault(); + event.stopPropagation(); + this.isOver = false; + } + + @HostListener('drop', ['$event']) + onDrop(event: DragEvent): void { + event.preventDefault(); + event.stopPropagation(); + this.isOver = false; + if (this.dropDisabled) return; + const list = event.dataTransfer?.files; + if (!list || list.length === 0) return; + const files = Array.from(list); + this.filesDropped.emit(this.multiple ? files : [files[0]]); + } +} diff --git a/web/src/app/shared/image-uploader/image-uploader.component.html b/web/src/app/shared/image-uploader/image-uploader.component.html index 3f7e319..6934cec 100644 --- a/web/src/app/shared/image-uploader/image-uploader.component.html +++ b/web/src/app/shared/image-uploader/image-uploader.component.html @@ -1,6 +1,12 @@ @if (compact) { -