Ajout de drag'n drop là ou c'est possible ; ajout de l'import des ennemis depuis foundryVTT
All checks were successful
All checks were successful
This commit is contained in:
@@ -81,6 +81,55 @@ public class EnemyService {
|
|||||||
enemyRepository.deleteById(id);
|
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<MonsterImport> monsters) {
|
||||||
|
List<Enemy> existing = enemyRepository.findByCampaignId(campaignId);
|
||||||
|
Map<String, Enemy> 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<Enemy> searchEnemies(String query) {
|
public List<Enemy> searchEnemies(String query) {
|
||||||
if (query == null || query.isBlank()) return List.of();
|
if (query == null || query.isBlank()) return List.of();
|
||||||
return enemyRepository.searchByName(query.trim());
|
return enemyRepository.searchByName(query.trim());
|
||||||
|
|||||||
@@ -47,6 +47,14 @@ public class Enemy {
|
|||||||
/** Référence vers la Campaign parente (cross-aggregate via ID). */
|
/** Référence vers la Campaign parente (cross-aggregate via ID). */
|
||||||
private String campaignId;
|
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. */
|
/** Ordre d'affichage dans la liste. */
|
||||||
private int order;
|
private int order;
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ public class EnemyJpaEntity {
|
|||||||
@Column(name = "campaign_id", nullable = false)
|
@Column(name = "campaign_id", nullable = false)
|
||||||
private Long campaignId;
|
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)
|
@Column(name = "\"order\"", nullable = false)
|
||||||
private int order;
|
private int order;
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ public class PostgresEnemyRepository implements EnemyRepository {
|
|||||||
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
|
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
|
||||||
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
|
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
|
||||||
.campaignId(e.getCampaignId().toString())
|
.campaignId(e.getCampaignId().toString())
|
||||||
|
.foundryRef(e.getFoundryRef())
|
||||||
.order(e.getOrder())
|
.order(e.getOrder())
|
||||||
.createdAt(e.getCreatedAt())
|
.createdAt(e.getCreatedAt())
|
||||||
.updatedAt(e.getUpdatedAt())
|
.updatedAt(e.getUpdatedAt())
|
||||||
@@ -79,6 +80,7 @@ public class PostgresEnemyRepository implements EnemyRepository {
|
|||||||
.imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>())
|
.imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>())
|
||||||
.keyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>())
|
.keyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>())
|
||||||
.campaignId(Long.parseLong(n.getCampaignId()))
|
.campaignId(Long.parseLong(n.getCampaignId()))
|
||||||
|
.foundryRef(n.getFoundryRef())
|
||||||
.order(n.getOrder())
|
.order(n.getOrder())
|
||||||
.createdAt(n.getCreatedAt())
|
.createdAt(n.getCreatedAt())
|
||||||
.updatedAt(n.getUpdatedAt())
|
.updatedAt(n.getUpdatedAt())
|
||||||
|
|||||||
@@ -364,7 +364,7 @@ public class ExportService {
|
|||||||
private ContentExport.EnemyDto toEnemyDto(EnemyJpaEntity e) {
|
private ContentExport.EnemyDto toEnemyDto(EnemyJpaEntity e) {
|
||||||
return new ContentExport.EnemyDto(e.getId(), e.getName(), e.getLevel(), e.getFolder(),
|
return new ContentExport.EnemyDto(e.getId(), e.getName(), e.getLevel(), e.getFolder(),
|
||||||
e.getPortraitImageId(), e.getHeaderImageId(), e.getValues(), e.getImageValues(),
|
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) {
|
private ContentExport.ItemCatalogDto toItemCatalogDto(ItemCatalogJpaEntity e) {
|
||||||
|
|||||||
@@ -335,6 +335,7 @@ public class ImportService {
|
|||||||
e.setImageValues(d.imageValues());
|
e.setImageValues(d.imageValues());
|
||||||
e.setKeyValueValues(d.keyValueValues());
|
e.setKeyValueValues(d.keyValueValues());
|
||||||
e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
|
e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
|
||||||
|
e.setFoundryRef(d.foundryRef()); // ref externe Foundry : conservee telle quelle
|
||||||
e.setOrder(d.order());
|
e.setOrder(d.order());
|
||||||
enemyMap.put(d.id(), enemyRepo.save(e).getId());
|
enemyMap.put(d.id(), enemyRepo.save(e).getId());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ public record ContentExport(
|
|||||||
Map<String, List<String>> imageValues,
|
Map<String, List<String>> imageValues,
|
||||||
Map<String, Map<String, String>> keyValueValues,
|
Map<String, Map<String, String>> keyValueValues,
|
||||||
Long campaignId,
|
Long campaignId,
|
||||||
|
String foundryRef,
|
||||||
int order
|
int order
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ public final class FoundryBundle {
|
|||||||
List<Scene> scenes,
|
List<Scene> scenes,
|
||||||
List<Persona> npcs,
|
List<Persona> npcs,
|
||||||
List<Persona> enemies,
|
List<Persona> enemies,
|
||||||
|
List<RandomTable> randomTables,
|
||||||
List<Asset> assets
|
List<Asset> assets
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -75,7 +76,8 @@ public final class FoundryBundle {
|
|||||||
|
|
||||||
public record Persona(
|
public record Persona(
|
||||||
String id, String name, String folder, int order,
|
String id, String name, String folder, int order,
|
||||||
String portraitAssetId, String headerAssetId, String level, List<Field> fields
|
String portraitAssetId, String headerAssetId, String level,
|
||||||
|
String foundryRef, List<Field> fields
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Champ de fiche resolu : {type, label} + selon le type value | entries | assetIds. */
|
/** 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) {}
|
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<RandomTableEntry> 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) {}
|
public record Asset(String id, String kind, String path, String filename, String mime, long sizeBytes) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ public class FoundryExportService {
|
|||||||
private final SceneJpaRepository sceneRepo;
|
private final SceneJpaRepository sceneRepo;
|
||||||
private final NpcJpaRepository npcRepo;
|
private final NpcJpaRepository npcRepo;
|
||||||
private final EnemyJpaRepository enemyRepo;
|
private final EnemyJpaRepository enemyRepo;
|
||||||
|
private final RandomTableJpaRepository randomTableRepo;
|
||||||
private final GameSystemJpaRepository gameSystemRepo;
|
private final GameSystemJpaRepository gameSystemRepo;
|
||||||
private final ImageJpaRepository imageRepo;
|
private final ImageJpaRepository imageRepo;
|
||||||
private final StoredFileJpaRepository storedFileRepo;
|
private final StoredFileJpaRepository storedFileRepo;
|
||||||
@@ -57,6 +58,7 @@ public class FoundryExportService {
|
|||||||
SceneJpaRepository sceneRepo,
|
SceneJpaRepository sceneRepo,
|
||||||
NpcJpaRepository npcRepo,
|
NpcJpaRepository npcRepo,
|
||||||
EnemyJpaRepository enemyRepo,
|
EnemyJpaRepository enemyRepo,
|
||||||
|
RandomTableJpaRepository randomTableRepo,
|
||||||
GameSystemJpaRepository gameSystemRepo,
|
GameSystemJpaRepository gameSystemRepo,
|
||||||
ImageJpaRepository imageRepo,
|
ImageJpaRepository imageRepo,
|
||||||
StoredFileJpaRepository storedFileRepo,
|
StoredFileJpaRepository storedFileRepo,
|
||||||
@@ -70,6 +72,7 @@ public class FoundryExportService {
|
|||||||
this.sceneRepo = sceneRepo;
|
this.sceneRepo = sceneRepo;
|
||||||
this.npcRepo = npcRepo;
|
this.npcRepo = npcRepo;
|
||||||
this.enemyRepo = enemyRepo;
|
this.enemyRepo = enemyRepo;
|
||||||
|
this.randomTableRepo = randomTableRepo;
|
||||||
this.gameSystemRepo = gameSystemRepo;
|
this.gameSystemRepo = gameSystemRepo;
|
||||||
this.imageRepo = imageRepo;
|
this.imageRepo = imageRepo;
|
||||||
this.storedFileRepo = storedFileRepo;
|
this.storedFileRepo = storedFileRepo;
|
||||||
@@ -130,7 +133,7 @@ public class FoundryExportService {
|
|||||||
for (NpcJpaEntity n : npcRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) {
|
for (NpcJpaEntity n : npcRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) {
|
||||||
npcs.add(new FoundryBundle.Persona(
|
npcs.add(new FoundryBundle.Persona(
|
||||||
str(n.getId()), n.getName(), n.getFolder(), n.getOrder(),
|
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)));
|
fields(npcTemplate, n.getValues(), n.getKeyValueValues(), n.getImageValues(), assets)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,14 +142,28 @@ public class FoundryExportService {
|
|||||||
enemies.add(new FoundryBundle.Persona(
|
enemies.add(new FoundryBundle.Persona(
|
||||||
str(e.getId()), e.getName(), e.getFolder(), e.getOrder(),
|
str(e.getId()), e.getName(), e.getFolder(), e.getOrder(),
|
||||||
assets.image(e.getPortraitImageId()), assets.image(e.getHeaderImageId()), e.getLevel(),
|
assets.image(e.getPortraitImageId()), assets.image(e.getHeaderImageId()), e.getLevel(),
|
||||||
|
e.getFoundryRef(),
|
||||||
fields(enemyTemplate, e.getValues(), e.getKeyValueValues(), e.getImageValues(), assets)));
|
fields(enemyTemplate, e.getValues(), e.getKeyValueValues(), e.getImageValues(), assets)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<FoundryBundle.RandomTable> randomTables = new ArrayList<>();
|
||||||
|
for (RandomTableJpaEntity t : randomTableRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) {
|
||||||
|
List<FoundryBundle.RandomTableEntry> 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(
|
FoundryBundle.Campaign campaignNode = new FoundryBundle.Campaign(
|
||||||
str(campaign.getId()), campaign.getName(), campaign.getDescription(), campaign.getGameSystemId());
|
str(campaign.getId()), campaign.getName(), campaign.getDescription(), campaign.getGameSystemId());
|
||||||
|
|
||||||
FoundryBundle.Data data = new FoundryBundle.Data(
|
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<String, Integer> counts = new LinkedHashMap<>();
|
Map<String, Integer> counts = new LinkedHashMap<>();
|
||||||
counts.put("arcs", arcs.size());
|
counts.put("arcs", arcs.size());
|
||||||
@@ -154,6 +171,7 @@ public class FoundryExportService {
|
|||||||
counts.put("scenes", scenes.size());
|
counts.put("scenes", scenes.size());
|
||||||
counts.put("npcs", npcs.size());
|
counts.put("npcs", npcs.size());
|
||||||
counts.put("enemies", enemies.size());
|
counts.put("enemies", enemies.size());
|
||||||
|
counts.put("randomTables", randomTables.size());
|
||||||
counts.put("assets", assets.assets().size());
|
counts.put("assets", assets.assets().size());
|
||||||
|
|
||||||
FoundryBundle.Manifest manifest = new FoundryBundle.Manifest(
|
FoundryBundle.Manifest manifest = new FoundryBundle.Manifest(
|
||||||
|
|||||||
@@ -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<MonsterEntry> monsters) {}
|
||||||
|
|
||||||
|
public record MonsterEntry(String name, String uuid, String img) {}
|
||||||
|
|
||||||
|
@PostMapping("/import-foundry-monsters")
|
||||||
|
public ResponseEntity<EnemyService.MonsterImportResult> importMonsters(
|
||||||
|
@PathVariable String campaignId,
|
||||||
|
@RequestBody MonsterCatalog catalog) {
|
||||||
|
List<EnemyService.MonsterImport> monsters = (catalog.monsters() == null ? List.<MonsterEntry>of() : catalog.monsters())
|
||||||
|
.stream()
|
||||||
|
.map(m -> new EnemyService.MonsterImport(m.name(), m.uuid()))
|
||||||
|
.toList();
|
||||||
|
return ResponseEntity.ok(enemyService.importFoundryMonsters(campaignId, monsters));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ class FoundryExportServiceTest {
|
|||||||
@Autowired private GameSystemJpaRepository gameSystemRepo;
|
@Autowired private GameSystemJpaRepository gameSystemRepo;
|
||||||
@Autowired private ImageJpaRepository imageRepo;
|
@Autowired private ImageJpaRepository imageRepo;
|
||||||
@Autowired private StoredFileJpaRepository fileRepo;
|
@Autowired private StoredFileJpaRepository fileRepo;
|
||||||
|
@Autowired private RandomTableJpaRepository tableRepo;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void buildBundle_assemblesFullCampaign_withBattlemapAssetsAndResolvedNpcFields() {
|
void buildBundle_assemblesFullCampaign_withBattlemapAssetsAndResolvedNpcFields() {
|
||||||
@@ -76,6 +77,19 @@ class FoundryExportServiceTest {
|
|||||||
.keyValueValues(Map.of("Caracs", Map.of("FOR", "16")))
|
.keyValueValues(Map.of("Caracs", Map.of("FOR", "16")))
|
||||||
.build());
|
.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");
|
FoundryExportService.BuiltBundle bundle = service.buildBundle(String.valueOf(camp.getId()), "2026-06-25T00:00:00Z");
|
||||||
FoundryBundle.Data data = bundle.data();
|
FoundryBundle.Data data = bundle.data();
|
||||||
|
|
||||||
@@ -119,10 +133,21 @@ class FoundryExportServiceTest {
|
|||||||
assertEquals("FOR", caracs.entries().get(0).label());
|
assertEquals("FOR", caracs.entries().get(0).label());
|
||||||
assertEquals("16", caracs.entries().get(0).value());
|
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
|
// Manifest
|
||||||
assertEquals("1.0", bundle.manifest().formatVersion());
|
assertEquals("1.0", bundle.manifest().formatVersion());
|
||||||
assertEquals("plain", bundle.manifest().contentFormat());
|
assertEquals("plain", bundle.manifest().contentFormat());
|
||||||
assertEquals(1, bundle.manifest().counts().get("scenes"));
|
assertEquals(1, bundle.manifest().counts().get("scenes"));
|
||||||
|
assertEquals(1, bundle.manifest().counts().get("randomTables"));
|
||||||
assertEquals(3, bundle.manifest().counts().get("assets"));
|
assertEquals(3, bundle.manifest().counts().get("assets"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
<!-- Étape 1 : upload (masqué une fois en revue) -->
|
<!-- Étape 1 : upload (masqué une fois en revue) -->
|
||||||
@if (!reviewing) {
|
@if (!reviewing) {
|
||||||
<section class="upload-area">
|
<section class="upload-area" appFileDrop [dropDisabled]="importing" (filesDropped)="onPdfDropped($event)">
|
||||||
<input #pdfInput type="file" accept="application/pdf,.pdf" hidden (change)="onPdfSelected($event)" />
|
<input #pdfInput type="file" accept="application/pdf,.pdf" hidden (change)="onPdfSelected($event)" />
|
||||||
<button type="button" class="btn-primary big" [disabled]="importing" (click)="pdfInput.click()">
|
<button type="button" class="btn-primary big" [disabled]="importing" (click)="pdfInput.click()">
|
||||||
<lucide-icon [img]="Upload" [size]="16"></lucide-icon>
|
<lucide-icon [img]="Upload" [size]="16"></lucide-icon>
|
||||||
|
|||||||
@@ -32,6 +32,9 @@
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background: #0b1220;
|
background: #0b1220;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
|
||||||
|
&.drag-over { border-color: #667eea; background: #141d33; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary,
|
.btn-primary,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { CampaignImportProposal } from '../../../services/campaign-import.model'
|
|||||||
import { loadCampaignTreeData, CampaignTreeData } from '../../campaign-tree.helper';
|
import { loadCampaignTreeData, CampaignTreeData } from '../../campaign-tree.helper';
|
||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
import { catchError } from 'rxjs/operators';
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { FileDropDirective } from '../../../shared/file-drop.directive';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nœuds éditables (= proposition + état d'UI). `existing` = déjà présent dans la
|
* Nœuds éditables (= proposition + état d'UI). `existing` = déjà présent dans la
|
||||||
@@ -54,7 +55,7 @@ interface NpcNode { name: string; description: string; selected: boolean; existi
|
|||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-campaign-import',
|
selector: 'app-campaign-import',
|
||||||
imports: [FormsModule, LucideAngularModule, TranslatePipe],
|
imports: [FormsModule, LucideAngularModule, TranslatePipe, FileDropDirective],
|
||||||
templateUrl: './campaign-import.component.html',
|
templateUrl: './campaign-import.component.html',
|
||||||
styleUrls: ['./campaign-import.component.scss']
|
styleUrls: ['./campaign-import.component.scss']
|
||||||
})
|
})
|
||||||
@@ -135,6 +136,11 @@ export class CampaignImportComponent implements OnInit {
|
|||||||
if (file) this.importPdf(file);
|
if (file) this.importPdf(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Drop d'un PDF sur la zone d'upload. */
|
||||||
|
onPdfDropped(files: File[]): void {
|
||||||
|
if (!this.importing && files[0]) this.importPdf(files[0]);
|
||||||
|
}
|
||||||
|
|
||||||
private importPdf(file: File): void {
|
private importPdf(file: File): void {
|
||||||
this.importing = true;
|
this.importing = true;
|
||||||
this.reviewing = false;
|
this.reviewing = false;
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
<div class="sbl-page">
|
<div class="sbl-page">
|
||||||
<div class="sbl-toolbar">
|
<div class="sbl-toolbar" appFileDrop [dropDisabled]="importing" (filesDropped)="onMonstersDropped($event)">
|
||||||
<button class="btn-back" (click)="back()">
|
<button class="btn-back" (click)="back()">
|
||||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> {{ 'common.back' | translate }}
|
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> {{ 'common.back' | translate }}
|
||||||
</button>
|
</button>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
|
<button class="btn-back" (click)="importMonsters()" [disabled]="importing">
|
||||||
|
<lucide-icon [img]="Upload" [size]="14"></lucide-icon>
|
||||||
|
{{ (importing ? 'enemyList.importing' : 'enemyList.importMonsters') | translate }}
|
||||||
|
</button>
|
||||||
<button class="btn-create" (click)="create()">
|
<button class="btn-create" (click)="create()">
|
||||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> {{ 'enemyList.create' | translate }}
|
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> {{ 'enemyList.create' | translate }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
.sbl-toolbar {
|
.sbl-toolbar {
|
||||||
display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem;
|
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; }
|
.spacer { flex: 1; }
|
||||||
button {
|
button {
|
||||||
display: inline-flex; align-items: center; gap: 0.35rem;
|
display: inline-flex; align-items: center; gap: 0.35rem;
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { 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 { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||||
import { EnemyService } from '../../../services/enemy.service';
|
import { EnemyService } from '../../../services/enemy.service';
|
||||||
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||||
import { Enemy } from '../../../services/enemy.model';
|
import { Enemy } from '../../../services/enemy.model';
|
||||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
import { FileDropDirective } from '../../../shared/file-drop.directive';
|
||||||
|
|
||||||
/** Groupe d'affichage : un dossier (« Démons »…) et ses ennemis. */
|
/** Groupe d'affichage : un dossier (« Démons »…) et ses ennemis. */
|
||||||
interface FolderGroup {
|
interface FolderGroup {
|
||||||
@@ -20,7 +21,7 @@ interface FolderGroup {
|
|||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-enemy-list',
|
selector: 'app-enemy-list',
|
||||||
imports: [LucideAngularModule, TranslatePipe],
|
imports: [LucideAngularModule, TranslatePipe, FileDropDirective],
|
||||||
templateUrl: './enemy-list.component.html',
|
templateUrl: './enemy-list.component.html',
|
||||||
styleUrls: ['./enemy-list.component.scss']
|
styleUrls: ['./enemy-list.component.scss']
|
||||||
})
|
})
|
||||||
@@ -30,6 +31,10 @@ export class EnemyListComponent implements OnInit {
|
|||||||
readonly Trash2 = Trash2;
|
readonly Trash2 = Trash2;
|
||||||
readonly Skull = Skull;
|
readonly Skull = Skull;
|
||||||
readonly Folder = Folder;
|
readonly Folder = Folder;
|
||||||
|
readonly Upload = Upload;
|
||||||
|
|
||||||
|
/** Import de monstres Foundry en cours (anti double-clic). */
|
||||||
|
importing = false;
|
||||||
|
|
||||||
campaignId = '';
|
campaignId = '';
|
||||||
/** Groupes triés par nom de dossier ; les non-classés en dernier (folder = ''). */
|
/** Groupes triés par nom de dossier ; les non-classés en dernier (folder = ''). */
|
||||||
@@ -86,6 +91,45 @@ export class EnemyListComponent implements OnInit {
|
|||||||
this.router.navigate(['/campaigns', this.campaignId, 'enemies', 'create']);
|
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 {
|
open(e: Enemy): void {
|
||||||
this.router.navigate(['/campaigns', this.campaignId, 'enemies', e.id]);
|
this.router.navigate(['/campaigns', this.campaignId, 'enemies', e.id]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,9 @@
|
|||||||
<label>{{ 'sceneEdit.battlemapLabel' | translate }}</label>
|
<label>{{ 'sceneEdit.battlemapLabel' | translate }}</label>
|
||||||
<small class="field-hint">{{ 'sceneEdit.battlemapHint' | translate }}</small>
|
<small class="field-hint">{{ 'sceneEdit.battlemapHint' | translate }}</small>
|
||||||
|
|
||||||
<div class="battlemap-slot">
|
<div class="battlemap-slot"
|
||||||
|
appFileDrop [dropDisabled]="battlemapUploadingMedia"
|
||||||
|
(filesDropped)="onBattlemapMediaDropped($event)">
|
||||||
<span class="battlemap-slot-label">{{ 'sceneEdit.battlemapMedia' | translate }}</span>
|
<span class="battlemap-slot-label">{{ 'sceneEdit.battlemapMedia' | translate }}</span>
|
||||||
@if (battlemapMediaFileId) {
|
@if (battlemapMediaFileId) {
|
||||||
<span class="battlemap-file">{{ battlemapMediaName || ('sceneEdit.battlemapAttached' | translate) }}</span>
|
<span class="battlemap-file">{{ battlemapMediaName || ('sceneEdit.battlemapAttached' | translate) }}</span>
|
||||||
@@ -58,7 +60,9 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="battlemap-slot">
|
<div class="battlemap-slot"
|
||||||
|
appFileDrop [dropDisabled]="battlemapUploadingData"
|
||||||
|
(filesDropped)="onBattlemapDataDropped($event)">
|
||||||
<span class="battlemap-slot-label">{{ 'sceneEdit.battlemapData' | translate }}</span>
|
<span class="battlemap-slot-label">{{ 'sceneEdit.battlemapData' | translate }}</span>
|
||||||
@if (battlemapDataFileId) {
|
@if (battlemapDataFileId) {
|
||||||
<span class="battlemap-file">{{ battlemapDataName || ('sceneEdit.battlemapAttached' | translate) }}</span>
|
<span class="battlemap-file">{{ battlemapDataName || ('sceneEdit.battlemapAttached' | translate) }}</span>
|
||||||
|
|||||||
@@ -8,7 +8,15 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
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 {
|
.battlemap-slot-label {
|
||||||
min-width: 160px;
|
min-width: 160px;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-dr
|
|||||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||||
import { RoomsEditorComponent } from '../../../shared/rooms-editor/rooms-editor.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 { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
|
|||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-scene-edit',
|
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',
|
templateUrl: './scene-edit.component.html',
|
||||||
styleUrls: ['./scene-edit.component.scss']
|
styleUrls: ['./scene-edit.component.scss']
|
||||||
})
|
})
|
||||||
@@ -282,7 +283,15 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
|||||||
onBattlemapMediaSelected(event: Event): void {
|
onBattlemapMediaSelected(event: Event): void {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
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.battlemapUploadingMedia = true;
|
||||||
this.storedFileService.upload(file).subscribe({
|
this.storedFileService.upload(file).subscribe({
|
||||||
next: f => {
|
next: f => {
|
||||||
@@ -292,13 +301,20 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
|||||||
},
|
},
|
||||||
error: () => { this.battlemapUploadingMedia = false; }
|
error: () => { this.battlemapUploadingMedia = false; }
|
||||||
});
|
});
|
||||||
input.value = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onBattlemapDataSelected(event: Event): void {
|
onBattlemapDataSelected(event: Event): void {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
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.battlemapUploadingData = true;
|
||||||
this.storedFileService.upload(file).subscribe({
|
this.storedFileService.upload(file).subscribe({
|
||||||
next: f => {
|
next: f => {
|
||||||
@@ -308,7 +324,6 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
|||||||
},
|
},
|
||||||
error: () => { this.battlemapUploadingData = false; }
|
error: () => { this.battlemapUploadingData = false; }
|
||||||
});
|
});
|
||||||
input.value = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
removeBattlemapMedia(): void {
|
removeBattlemapMedia(): void {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface Enemy {
|
|||||||
keyValueValues?: Record<string, Record<string, string>>;
|
keyValueValues?: Record<string, Record<string, string>>;
|
||||||
campaignId: string;
|
campaignId: string;
|
||||||
order?: number;
|
order?: number;
|
||||||
|
/** UUID de l'acteur de compendium Foundry d'origine (monstre importé). Null sinon. */
|
||||||
|
foundryRef?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnemyCreate {
|
export interface EnemyCreate {
|
||||||
|
|||||||
@@ -36,4 +36,18 @@ export class EnemyService {
|
|||||||
search(q: string): Observable<Enemy[]> {
|
search(q: string): Observable<Enemy[]> {
|
||||||
return this.http.get<Enemy[]>(`${this.apiUrl}/search`, { params: { q } });
|
return this.http.get<Enemy[]>(`${this.apiUrl}/search`, { params: { q } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<MonsterImportResult> {
|
||||||
|
return this.http.post<MonsterImportResult>(
|
||||||
|
`/api/campaigns/${campaignId}/import-foundry-monsters`, catalog);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonsterImportResult {
|
||||||
|
created: number;
|
||||||
|
updated: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -376,7 +376,7 @@
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>{{ 'settings.data.title' | translate }}</h2>
|
<h2>{{ 'settings.data.title' | translate }}</h2>
|
||||||
<p class="hint" [innerHTML]="'settings.data.hint' | translate"></p>
|
<p class="hint" [innerHTML]="'settings.data.hint' | translate"></p>
|
||||||
<div class="actions">
|
<div class="actions" appFileDrop [dropDisabled]="exporting || importing" (filesDropped)="onBackupDropped($event)">
|
||||||
<button type="button" class="btn-secondary" (click)="exportData()" [disabled]="exporting || importing">
|
<button type="button" class="btn-secondary" (click)="exportData()" [disabled]="exporting || importing">
|
||||||
<span>{{ (exporting ? 'settings.data.exporting' : 'settings.data.exportBtn') | translate }}</span>
|
<span>{{ (exporting ? 'settings.data.exporting' : 'settings.data.exportBtn') | translate }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -290,6 +290,13 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: 24px;
|
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 {
|
.alert {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { LayoutService } from '../services/layout.service';
|
|||||||
import { UpdatesSectionComponent } from './updates-section/updates-section.component';
|
import { UpdatesSectionComponent } from './updates-section/updates-section.component';
|
||||||
import { OllamaModelManagerComponent } from './ollama-model-manager/ollama-model-manager.component';
|
import { OllamaModelManagerComponent } from './ollama-model-manager/ollama-model-manager.component';
|
||||||
import { LanguageSwitcherComponent } from '../shared/language-switcher/language-switcher.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.
|
* Ecran de parametrage du LLM utilise par le Brain.
|
||||||
@@ -26,7 +27,7 @@ import { LanguageSwitcherComponent } from '../shared/language-switcher/language-
|
|||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-settings',
|
selector: 'app-settings',
|
||||||
imports: [CommonModule, FormsModule, LucideAngularModule, TranslatePipe, UpdatesSectionComponent, OllamaModelManagerComponent, LanguageSwitcherComponent],
|
imports: [CommonModule, FormsModule, LucideAngularModule, TranslatePipe, UpdatesSectionComponent, OllamaModelManagerComponent, LanguageSwitcherComponent, FileDropDirective],
|
||||||
templateUrl: './settings.component.html',
|
templateUrl: './settings.component.html',
|
||||||
styleUrls: ['./settings.component.scss']
|
styleUrls: ['./settings.component.scss']
|
||||||
})
|
})
|
||||||
@@ -366,8 +367,16 @@ export class SettingsComponent implements OnInit {
|
|||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
input.value = ''; // permet de re-sélectionner le même fichier
|
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;
|
if (!confirm(this.translate.instant('settings.data.importConfirm'))) return;
|
||||||
|
|
||||||
this.importing = true;
|
this.importing = true;
|
||||||
|
|||||||
55
web/src/app/shared/file-drop.directive.ts
Normal file
55
web/src/app/shared/file-drop.directive.ts
Normal file
@@ -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 :
|
||||||
|
* <div appFileDrop (filesDropped)="onDrop($event)">…</div>
|
||||||
|
* <div appFileDrop [multiple]="true" (filesDropped)="onDrop($event)">…</div>
|
||||||
|
*/
|
||||||
|
@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<File[]>();
|
||||||
|
|
||||||
|
@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]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
<!-- Mode compact : bouton "+ ajouter" carre, utilise dans les galeries. -->
|
<!-- Mode compact : bouton "+ ajouter" carre, utilise dans les galeries. -->
|
||||||
@if (compact) {
|
@if (compact) {
|
||||||
<label class="upload-compact" [class.loading]="uploading" [title]="errorMessage || ('imageUploader.addImage' | translate)">
|
<label class="upload-compact"
|
||||||
|
[class.loading]="uploading"
|
||||||
|
[class.drag-over]="dragOver"
|
||||||
|
(dragover)="onDragOver($event)"
|
||||||
|
(dragleave)="onDragLeave()"
|
||||||
|
(drop)="onDrop($event)"
|
||||||
|
[title]="errorMessage || ('imageUploader.addImage' | translate)">
|
||||||
@if (!uploading) {
|
@if (!uploading) {
|
||||||
<lucide-icon [img]="Upload" [size]="18"></lucide-icon>
|
<lucide-icon [img]="Upload" [size]="18"></lucide-icon>
|
||||||
<span>{{ 'common.add' | translate }}</span>
|
<span>{{ 'common.add' | translate }}</span>
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||||
|
|
||||||
&:hover { border-color: #6c63ff; color: #a5b4fc; background: #1f1f3a; }
|
&:hover { border-color: #6c63ff; color: #a5b4fc; background: #1f1f3a; }
|
||||||
|
&.drag-over { border-color: #6c63ff; background: #1f1f3a; color: white; }
|
||||||
&.loading { cursor: progress; opacity: 0.7; }
|
&.loading { cursor: progress; opacity: 0.7; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1085,6 +1085,8 @@
|
|||||||
},
|
},
|
||||||
"enemyList": {
|
"enemyList": {
|
||||||
"create": "New enemy",
|
"create": "New enemy",
|
||||||
|
"importMonsters": "Import Foundry monsters",
|
||||||
|
"importing": "Importing…",
|
||||||
"title": "Enemies",
|
"title": "Enemies",
|
||||||
"hint": "The campaign bestiary: monsters and creatures with their sheet (driven by the game system's Enemy template), organized by folder (Demons, Humanoids…).",
|
"hint": "The campaign bestiary: monsters and creatures with their sheet (driven by the game system's Enemy template), organized by folder (Demons, Humanoids…).",
|
||||||
"empty": "No enemies yet.",
|
"empty": "No enemies yet.",
|
||||||
|
|||||||
@@ -1085,6 +1085,8 @@
|
|||||||
},
|
},
|
||||||
"enemyList": {
|
"enemyList": {
|
||||||
"create": "Nouvel ennemi",
|
"create": "Nouvel ennemi",
|
||||||
|
"importMonsters": "Importer des monstres Foundry",
|
||||||
|
"importing": "Import…",
|
||||||
"title": "Ennemis",
|
"title": "Ennemis",
|
||||||
"hint": "Le bestiaire de la campagne : monstres et créatures avec leur fiche (pilotée par le template Ennemi du système de jeu), classés par dossier (Démons, Humanoïdes…).",
|
"hint": "Le bestiaire de la campagne : monstres et créatures avec leur fiche (pilotée par le template Ennemi du système de jeu), classés par dossier (Démons, Humanoïdes…).",
|
||||||
"empty": "Aucun ennemi pour l'instant.",
|
"empty": "Aucun ennemi pour l'instant.",
|
||||||
|
|||||||
@@ -81,6 +81,8 @@
|
|||||||
},
|
},
|
||||||
"enemyList": {
|
"enemyList": {
|
||||||
"create": "New enemy",
|
"create": "New enemy",
|
||||||
|
"importMonsters": "Import Foundry monsters",
|
||||||
|
"importing": "Importing…",
|
||||||
"title": "Enemies",
|
"title": "Enemies",
|
||||||
"hint": "The campaign bestiary: monsters and creatures with their sheet (driven by the game system's Enemy template), organized by folder (Demons, Humanoids…).",
|
"hint": "The campaign bestiary: monsters and creatures with their sheet (driven by the game system's Enemy template), organized by folder (Demons, Humanoids…).",
|
||||||
"empty": "No enemies yet.",
|
"empty": "No enemies yet.",
|
||||||
|
|||||||
@@ -81,6 +81,8 @@
|
|||||||
},
|
},
|
||||||
"enemyList": {
|
"enemyList": {
|
||||||
"create": "Nouvel ennemi",
|
"create": "Nouvel ennemi",
|
||||||
|
"importMonsters": "Importer des monstres Foundry",
|
||||||
|
"importing": "Import…",
|
||||||
"title": "Ennemis",
|
"title": "Ennemis",
|
||||||
"hint": "Le bestiaire de la campagne : monstres et créatures avec leur fiche (pilotée par le template Ennemi du système de jeu), classés par dossier (Démons, Humanoïdes…).",
|
"hint": "Le bestiaire de la campagne : monstres et créatures avec leur fiche (pilotée par le template Ennemi du système de jeu), classés par dossier (Démons, Humanoïdes…).",
|
||||||
"empty": "Aucun ennemi pour l'instant.",
|
"empty": "Aucun ennemi pour l'instant.",
|
||||||
|
|||||||
Reference in New Issue
Block a user