From da2a66d06a6811c98ba3e2e061d2153dfb46c2f0 Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Mon, 8 Jun 2026 17:00:22 +0200 Subject: [PATCH] =?UTF-8?q?Am=C3=A9lioration=20de=20l'IA=20pour=20la=20par?= =?UTF-8?q?tie=20atelier=20PDF=20Mise=20en=20place=20d'un=20outil=20permet?= =?UTF-8?q?tant=20de=20faire=20des=20tableau=20d'objets=20pour=20des=20bou?= =?UTF-8?q?tiques=20par=20exemple?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- brain/app/application/notebook_deep.py | 31 +++- brain/app/main.py | 74 +++++++++ .../campaigncontext/ItemCatalogService.java | 122 ++++++++++++++ .../domain/campaigncontext/CatalogItem.java | 24 +++ .../domain/campaigncontext/ItemCatalog.java | 44 +++++ .../ports/ItemCatalogGenerationException.java | 15 ++ .../ports/ItemCatalogGenerator.java | 21 +++ .../ports/ItemCatalogRepository.java | 22 +++ .../ai/BrainItemCatalogClient.java | 92 +++++++++++ .../entity/CatalogItemJpaEntity.java | 49 ++++++ .../entity/ItemCatalogJpaEntity.java | 71 ++++++++ .../jpa/ItemCatalogJpaRepository.java | 13 ++ .../PostgresItemCatalogRepository.java | 101 ++++++++++++ .../web/controller/ItemCatalogController.java | 84 ++++++++++ .../dto/campaigncontext/CatalogItemDTO.java | 14 ++ .../dto/campaigncontext/ItemCatalogDTO.java | 20 +++ .../web/mapper/ItemCatalogMapper.java | 50 ++++++ web/src/app/app.routes.ts | 4 + web/src/app/campaigns/campaign-tree.helper.ts | 10 +- .../item-catalog-edit.component.html | 68 ++++++++ .../item-catalog-edit.component.scss | 87 ++++++++++ .../item-catalog-edit.component.ts | 152 ++++++++++++++++++ .../item-catalog-list.component.html | 31 ++++ .../item-catalog-list.component.scss | 36 +++++ .../item-catalog-list.component.ts | 77 +++++++++ .../item-catalog-view.component.html | 33 ++++ .../item-catalog-view.component.scss | 40 +++++ .../item-catalog-view.component.ts | 73 +++++++++ web/src/app/lore/lore-icons.ts | 3 +- web/src/app/services/item-catalog.model.ts | 26 +++ web/src/app/services/item-catalog.service.ts | 39 +++++ .../session-detail.component.html | 3 +- .../session-detail.component.ts | 16 ++ ...session-item-catalogs-panel.component.html | 38 +++++ ...session-item-catalogs-panel.component.scss | 48 ++++++ .../session-item-catalogs-panel.component.ts | 64 ++++++++ .../session-reference-panel.component.html | 15 ++ .../session-reference-panel.component.ts | 14 +- 38 files changed, 1712 insertions(+), 12 deletions(-) create mode 100644 core/src/main/java/com/loremind/application/campaigncontext/ItemCatalogService.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/CatalogItem.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/ItemCatalog.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/ports/ItemCatalogGenerationException.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/ports/ItemCatalogGenerator.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/ports/ItemCatalogRepository.java create mode 100644 core/src/main/java/com/loremind/infrastructure/ai/BrainItemCatalogClient.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/entity/CatalogItemJpaEntity.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/entity/ItemCatalogJpaEntity.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/jpa/ItemCatalogJpaRepository.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresItemCatalogRepository.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/ItemCatalogController.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CatalogItemDTO.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/ItemCatalogDTO.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/mapper/ItemCatalogMapper.java create mode 100644 web/src/app/campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component.html create mode 100644 web/src/app/campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component.scss create mode 100644 web/src/app/campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component.ts create mode 100644 web/src/app/campaigns/item-catalog/item-catalog-list/item-catalog-list.component.html create mode 100644 web/src/app/campaigns/item-catalog/item-catalog-list/item-catalog-list.component.scss create mode 100644 web/src/app/campaigns/item-catalog/item-catalog-list/item-catalog-list.component.ts create mode 100644 web/src/app/campaigns/item-catalog/item-catalog-view/item-catalog-view.component.html create mode 100644 web/src/app/campaigns/item-catalog/item-catalog-view/item-catalog-view.component.scss create mode 100644 web/src/app/campaigns/item-catalog/item-catalog-view/item-catalog-view.component.ts create mode 100644 web/src/app/services/item-catalog.model.ts create mode 100644 web/src/app/services/item-catalog.service.ts create mode 100644 web/src/app/sessions/session-item-catalogs-panel/session-item-catalogs-panel.component.html create mode 100644 web/src/app/sessions/session-item-catalogs-panel/session-item-catalogs-panel.component.scss create mode 100644 web/src/app/sessions/session-item-catalogs-panel/session-item-catalogs-panel.component.ts diff --git a/brain/app/application/notebook_deep.py b/brain/app/application/notebook_deep.py index b89a68f..7030ece 100644 --- a/brain/app/application/notebook_deep.py +++ b/brain/app/application/notebook_deep.py @@ -41,11 +41,17 @@ QUESTION : {question} Informations pertinentes (ou « {no_match} ») :""" -_REDUCE_SYSTEM = """Tu réponds à la question d'un MJ à partir de NOTES extraites de -l'ENSEMBLE d'un document source (donc tu as une vue COMPLÈTE, pas un simple extrait). -Synthétise ces notes en une réponse claire et structurée, cite les pages (« p. X »), -et n'invente rien qui n'y figure pas. Si une CAMPAGNE est fournie ci-dessous, relie ta -réponse à sa structure / ses PNJ pour des adaptations cohérentes. +_REDUCE_SYSTEM = """Tu es l'assistant-MJ d'un jeu de rôle. Tu réponds à la demande du MJ en +t'appuyant sur TROIS sources : (1) des NOTES extraites de l'ENSEMBLE du document source (vue +complète — mais POSSIBLEMENT VIDE si rien d'utile n'y figure), (2) le contexte de sa CAMPAGNE, +(3) la conversation ci-dessous. + +- Si les notes contiennent des éléments utiles : exploite-les et CITE les pages (« p. X »). +- Si les notes sont VIDES ou pauvres (cas fréquent d'une demande CRÉATIVE portant sur des + éléments INVENTÉS par le MJ) : ne te bloque surtout PAS. Aide-le quand même en t'appuyant + sur sa CAMPAGNE, la CONVERSATION et ta connaissance du genre — propose des adaptations + concrètes (arcs, chapitres, scènes, PNJ), structurées et jouables. +- Sois concret et utile. N'affirme rien de FAUX sur le contenu du document. {context_block} --- NOTES EXTRAITES DE TOUT LE DOCUMENT --- @@ -113,8 +119,21 @@ class NotebookDeepUseCase: # dernier message est bien la question courante. reduce_messages = messages[-history_limit:] if messages else [ChatMessage(role="user", content=question)] llm_chat: LLMChatProvider = self._llm # type: ignore[assignment] + produced = False async for token in llm_chat.stream_chat(reduce_messages, system_prompt=system_prompt): - yield {"type": "token", "token": token} + if token: + produced = True + yield {"type": "token", "token": token} + if not produced: + # Jamais de bulle vide : message de repli + orientation vers le mode rapide, + # mieux adapté aux demandes créatives (et qui propose des cartes d'action). + yield {"type": "token", "token": ( + "Je n'ai pas trouvé d'éléments pertinents dans le document pour cette demande " + "(elle porte sans doute sur des éléments que tu as inventés). Pour une " + "**adaptation créative** — proposer des arcs, chapitres, scènes ou PNJ — " + "utilise plutôt le bouton **« Envoyer »** (mode rapide) : il est conversationnel, " + "voit ta campagne, et te propose des cartes « Créer dans la campagne »." + )} yield {"type": "done"} def _group(self, chunks: list[dict]) -> list[list[dict]]: diff --git a/brain/app/main.py b/brain/app/main.py index cb5b57a..50e2f4c 100644 --- a/brain/app/main.py +++ b/brain/app/main.py @@ -1015,6 +1015,80 @@ async def improvise_table_roll( return ImproviseRollResponseDTO(narration=raw.strip()) +# --- Catalogues d'objets (boutiques) : génération IA ------------------------- + + +class GenerateCatalogRequestDTO(BaseModel): + description: str + context: str = Field(default="") + + +class GeneratedCatalogItemDTO(BaseModel): + name: str + price: str = "" + category: str = "" + description: str = "" + + +class GenerateCatalogResponseDTO(BaseModel): + name: str + description: str = "" + items: list[GeneratedCatalogItemDTO] + + +@app.post("/generate/item-catalog", response_model=GenerateCatalogResponseDTO) +async def generate_item_catalog( + body: GenerateCatalogRequestDTO, + llm: Annotated[LLMProvider, Depends(get_llm_provider)], +) -> GenerateCatalogResponseDTO: + """Génère un catalogue d'objets (boutique, butin…) — nom, prix, catégorie, description.""" + context_block = f"\nContexte de la campagne :\n{body.context.strip()}\n" if body.context.strip() else "" + prompt = ( + "Tu es un assistant de jeu de rôle. Génère un CATALOGUE D'OBJETS (boutique, butin, trésor…).\n" + f"Sujet : {body.description.strip()}\n" + f"{context_block}\n" + "Règles IMPÉRATIVES :\n" + "- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n" + '- Format : {"name": "...", "description": "...", "items": ' + '[{"name": "Objet", "price": "ex. 50 po", "category": "ex. Armes", "description": "effet/détails"}]}\n' + "- Des objets variés et cohérents avec le sujet (et le contexte s'il est fourni).\n" + "- 'price' = prix court dans la monnaie du jeu ; 'category' = regroupement (Armes, Potions…) ; " + "'description' = effet/détails en une phrase. En français.\n" + "Renvoie maintenant le JSON." + ) + try: + raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.7) + except LLMProviderError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + parsed, _ = load_json_object(raw) + if not isinstance(parsed, dict): + raise HTTPException(status_code=502, detail="Le modèle n'a pas renvoyé de catalogue exploitable.") + + items: list[GeneratedCatalogItemDTO] = [] + for it in parsed.get("items", []) or []: + if not isinstance(it, dict): + continue + name = str(it.get("name") or "").strip() + if not name: + continue + items.append(GeneratedCatalogItemDTO( + name=name[:200], + price=str(it.get("price") or "").strip(), + category=str(it.get("category") or "").strip(), + description=str(it.get("description") or "").strip(), + )) + if not items: + raise HTTPException(status_code=502, detail="Aucun objet généré — réessaie ou reformule.") + + name = str(parsed.get("name") or body.description).strip()[:120] or "Catalogue généré" + return GenerateCatalogResponseDTO( + name=name, + description=str(parsed.get("description") or "").strip(), + items=items, + ) + + # --- Notebooks (atelier RAG) : indexation des sources + chat ancré ---------- diff --git a/core/src/main/java/com/loremind/application/campaigncontext/ItemCatalogService.java b/core/src/main/java/com/loremind/application/campaigncontext/ItemCatalogService.java new file mode 100644 index 0000000..366bf01 --- /dev/null +++ b/core/src/main/java/com/loremind/application/campaigncontext/ItemCatalogService.java @@ -0,0 +1,122 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.CatalogItem; +import com.loremind.domain.campaigncontext.ItemCatalog; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import com.loremind.domain.campaigncontext.ports.ItemCatalogGenerator; +import com.loremind.domain.campaigncontext.ports.ItemCatalogRepository; +import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Service d'application des catalogues d'objets (campagne) : CRUD + génération IA. + */ +@Service +public class ItemCatalogService { + + private final ItemCatalogRepository repository; + private final ItemCatalogGenerator generator; + private final CampaignRepository campaignRepository; + private final GameSystemRepository gameSystemRepository; + + public ItemCatalogService( + ItemCatalogRepository repository, + ItemCatalogGenerator generator, + CampaignRepository campaignRepository, + GameSystemRepository gameSystemRepository) { + this.repository = repository; + this.generator = generator; + this.campaignRepository = campaignRepository; + this.gameSystemRepository = gameSystemRepository; + } + + public record CatalogData( + String name, + String description, + String icon, + List items, + String campaignId, + Integer order + ) {} + + public ItemCatalog createCatalog(CatalogData data) { + int order = data.order() != null ? data.order() : nextOrderFor(data.campaignId()); + ItemCatalog catalog = ItemCatalog.builder() + .name(data.name()) + .description(data.description()) + .icon(data.icon()) + .items(copyItems(data.items())) + .campaignId(data.campaignId()) + .order(order) + .build(); + return repository.save(catalog); + } + + public Optional getCatalogById(String id) { + return repository.findById(id); + } + + public List getCatalogsByCampaignId(String campaignId) { + return repository.findByCampaignId(campaignId); + } + + public ItemCatalog updateCatalog(String id, CatalogData data) { + ItemCatalog existing = repository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Catalogue d'objets introuvable: " + id)); + existing.setName(data.name()); + existing.setDescription(data.description()); + existing.setIcon(data.icon()); + existing.setItems(copyItems(data.items())); + if (data.order() != null) { + existing.setOrder(data.order()); + } + return repository.save(existing); + } + + public void deleteCatalog(String id) { + repository.deleteById(id); + } + + /** Génère une PROPOSITION de catalogue (non persistée) via l'IA, contextualisée campagne. */ + public ItemCatalog generateProposal(String campaignId, String description) { + ItemCatalogGenerator.GeneratedCatalog g = generator.generate(description, buildContext(campaignId)); + return ItemCatalog.builder() + .name(g.name()) + .description(g.description()) + .campaignId(campaignId) + .items(g.items() != null ? new ArrayList<>(g.items()) : new ArrayList<>()) + .build(); + } + + private static List copyItems(List items) { + return items != null ? new ArrayList<>(items) : new ArrayList<>(); + } + + private String buildContext(String campaignId) { + if (campaignId == null) return ""; + Campaign campaign = campaignRepository.findById(campaignId).orElse(null); + if (campaign == null) return ""; + StringBuilder sb = new StringBuilder(); + sb.append("Campagne : ").append(campaign.getName()); + if (campaign.getDescription() != null && !campaign.getDescription().isBlank()) { + sb.append(" — ").append(campaign.getDescription().trim()); + } + if (campaign.getGameSystemId() != null && !campaign.getGameSystemId().isBlank()) { + gameSystemRepository.findById(campaign.getGameSystemId()) + .ifPresent(gs -> sb.append("\nSystème de jeu : ").append(gs.getName())); + } + return sb.toString(); + } + + private int nextOrderFor(String campaignId) { + return repository.findByCampaignId(campaignId).stream() + .mapToInt(ItemCatalog::getOrder) + .max() + .orElse(-1) + 1; + } +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/CatalogItem.java b/core/src/main/java/com/loremind/domain/campaigncontext/CatalogItem.java new file mode 100644 index 0000000..5164dc5 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/CatalogItem.java @@ -0,0 +1,24 @@ +package com.loremind.domain.campaigncontext; + +import lombok.Builder; +import lombok.Data; + +/** + * Un objet d'un {@link ItemCatalog} (boutique, butin, trésor…). + * Value object possédé par le catalogue : remplacé en bloc à chaque mise à jour. + */ +@Data +@Builder +public class CatalogItem { + + private String name; + + /** Prix libre (ex. « 50 po », « 2 pa »). Nullable. */ + private String price; + + /** Catégorie de regroupement (ex. « Armes », « Potions »). Nullable. */ + private String category; + + /** Description / effet (markdown). Nullable. */ + private String description; +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ItemCatalog.java b/core/src/main/java/com/loremind/domain/campaigncontext/ItemCatalog.java new file mode 100644 index 0000000..98d8620 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ItemCatalog.java @@ -0,0 +1,44 @@ +package com.loremind.domain.campaigncontext; + +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * Catalogue d'objets prédéfinis d'une campagne : une boutique, un butin, un trésor… + * Le MJ le remplit à la main ou via l'IA, et le consulte (notamment en session) quand + * les joueurs visitent une échoppe. Scope campagne (cross-aggregate via ID). + */ +@Data +@Builder +public class ItemCatalog { + + private String id; + private String name; + + /** Description libre (à quoi sert ce catalogue / cette boutique). Nullable. */ + private String description; + + /** Clé d'icône (lucide) pour la sidebar/fiche. Nullable. */ + private String icon; + + /** Référence vers la Campaign parente (cross-aggregate via ID). */ + private String campaignId; + + /** Ordre d'affichage dans la liste des catalogues de la campagne. */ + private int order; + + /** Objets ordonnés du catalogue. Jamais null après construction. */ + private List items; + + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public List getItems() { + if (items == null) items = new ArrayList<>(); + return items; + } +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/ItemCatalogGenerationException.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/ItemCatalogGenerationException.java new file mode 100644 index 0000000..ecc85e4 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/ItemCatalogGenerationException.java @@ -0,0 +1,15 @@ +package com.loremind.domain.campaigncontext.ports; + +/** + * Échec de génération IA d'un catalogue d'objets (Brain injoignable, erreur du + * modèle, réponse inexploitable…). Mappée en HTTP 502 par le contrôleur. + */ +public class ItemCatalogGenerationException extends RuntimeException { + public ItemCatalogGenerationException(String message) { + super(message); + } + + public ItemCatalogGenerationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/ItemCatalogGenerator.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/ItemCatalogGenerator.java new file mode 100644 index 0000000..d0711bb --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/ItemCatalogGenerator.java @@ -0,0 +1,21 @@ +package com.loremind.domain.campaigncontext.ports; + +import com.loremind.domain.campaigncontext.CatalogItem; + +import java.util.List; + +/** + * Port de sortie : génération IA d'un catalogue d'objets. Implémenté par un client + * du Brain (service IA Python). + */ +public interface ItemCatalogGenerator { + + /** Catalogue proposé (non persisté) à partir d'une description. */ + record GeneratedCatalog(String name, String description, List items) {} + + /** + * Génère une proposition de catalogue (objets) sur le sujet donné, en s'appuyant + * sur le contexte (campagne, système…) s'il est fourni. + */ + GeneratedCatalog generate(String description, String context); +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/ItemCatalogRepository.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/ItemCatalogRepository.java new file mode 100644 index 0000000..ef19140 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/ItemCatalogRepository.java @@ -0,0 +1,22 @@ +package com.loremind.domain.campaigncontext.ports; + +import com.loremind.domain.campaigncontext.ItemCatalog; + +import java.util.List; +import java.util.Optional; + +/** + * Port de sortie pour la persistance des {@link ItemCatalog}. + */ +public interface ItemCatalogRepository { + + ItemCatalog save(ItemCatalog catalog); + + Optional findById(String id); + + List findByCampaignId(String campaignId); + + void deleteById(String id); + + boolean existsById(String id); +} diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainItemCatalogClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainItemCatalogClient.java new file mode 100644 index 0000000..45e711e --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainItemCatalogClient.java @@ -0,0 +1,92 @@ +package com.loremind.infrastructure.ai; + +import com.loremind.domain.campaigncontext.CatalogItem; +import com.loremind.domain.campaigncontext.ports.ItemCatalogGenerationException; +import com.loremind.domain.campaigncontext.ports.ItemCatalogGenerator; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClientResponseException; +import org.springframework.web.client.RestTemplate; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Adapter de sortie : génère un catalogue d'objets via le Brain (POST one-shot). + */ +@Component +public class BrainItemCatalogClient implements ItemCatalogGenerator { + + private static final String GENERATE_PATH = "/generate/item-catalog"; + + private final RestTemplate restTemplate; + private final String baseUrl; + + public BrainItemCatalogClient( + RestTemplate restTemplate, + @Value("${brain.base-url}") String baseUrl) { + this.restTemplate = restTemplate; + this.baseUrl = baseUrl; + } + + @Override + @SuppressWarnings("unchecked") + public GeneratedCatalog generate(String description, String context) { + Map req = new LinkedHashMap<>(); + req.put("description", description == null ? "" : description); + req.put("context", context == null ? "" : context); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> entity = new HttpEntity<>(req, headers); + + Map resp; + try { + resp = restTemplate.postForObject(baseUrl + GENERATE_PATH, entity, Map.class); + } catch (ResourceAccessException e) { + throw new ItemCatalogGenerationException("Le Brain est injoignable (timeout ou arrêté).", e); + } catch (RestClientResponseException e) { + throw new ItemCatalogGenerationException( + "Le Brain a répondu HTTP " + e.getStatusCode().value() + " : " + e.getResponseBodyAsString(), e); + } catch (Exception e) { + throw new ItemCatalogGenerationException("Erreur inattendue lors de l'appel au Brain.", e); + } + if (resp == null) { + throw new ItemCatalogGenerationException("Le Brain a renvoyé une réponse vide."); + } + + List items = new ArrayList<>(); + Object rawItems = resp.get("items"); + if (rawItems instanceof List list) { + for (Object item : list) { + if (!(item instanceof Map m)) continue; + String name = asString(m.get("name")); + if (name == null || name.isBlank()) continue; + items.add(CatalogItem.builder() + .name(name) + .price(asString(m.get("price"))) + .category(asString(m.get("category"))) + .description(asString(m.get("description"))) + .build()); + } + } + if (items.isEmpty()) { + throw new ItemCatalogGenerationException("Aucun objet généré — réessaie ou reformule."); + } + String name = asString(resp.get("name")); + return new GeneratedCatalog( + name != null && !name.isBlank() ? name : description, + asString(resp.get("description")), + items); + } + + private static String asString(Object o) { + return o != null ? o.toString() : null; + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/CatalogItemJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/CatalogItemJpaEntity.java new file mode 100644 index 0000000..9fa36d6 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/CatalogItemJpaEntity.java @@ -0,0 +1,49 @@ +package com.loremind.infrastructure.persistence.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.ToString; + +/** + * Entité JPA d'un objet de catalogue (enfant de {@link ItemCatalogJpaEntity}). + * Ordonné par {@code position}. Référence parente exclue de toString/equals. + */ +@Entity +@Table(name = "catalog_items", indexes = { + @Index(name = "idx_catalog_items_catalog_id", columnList = "catalog_id") +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CatalogItemJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(length = 64) + private String price; + + @Column(length = 128) + private String category; + + @Column(columnDefinition = "TEXT") + private String description; + + @Column(nullable = false) + private int position; + + @ManyToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "catalog_id", nullable = false) + @ToString.Exclude + @EqualsAndHashCode.Exclude + private ItemCatalogJpaEntity catalog; +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/ItemCatalogJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/ItemCatalogJpaEntity.java new file mode 100644 index 0000000..b4ded93 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/ItemCatalogJpaEntity.java @@ -0,0 +1,71 @@ +package com.loremind.infrastructure.persistence.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * Entité JPA d'un catalogue d'objets (parent) avec ses objets ordonnés (enfants). + */ +@Entity +@Table(name = "item_catalogs", indexes = { + @Index(name = "idx_item_catalogs_campaign_id", columnList = "campaign_id") +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ItemCatalogJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(columnDefinition = "TEXT") + private String description; + + @Column(length = 64) + private String icon; + + @Column(name = "campaign_id", nullable = false) + private Long campaignId; + + @Column(name = "\"order\"", nullable = false) + private int order; + + @OneToMany( + mappedBy = "catalog", + cascade = CascadeType.ALL, + orphanRemoval = true, + fetch = FetchType.LAZY + ) + @OrderBy("position ASC") + @Builder.Default + private List items = new ArrayList<>(); + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/ItemCatalogJpaRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/ItemCatalogJpaRepository.java new file mode 100644 index 0000000..94f43e0 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/ItemCatalogJpaRepository.java @@ -0,0 +1,13 @@ +package com.loremind.infrastructure.persistence.jpa; + +import com.loremind.infrastructure.persistence.entity.ItemCatalogJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ItemCatalogJpaRepository extends JpaRepository { + + List findByCampaignIdOrderByOrderAsc(Long campaignId); +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresItemCatalogRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresItemCatalogRepository.java new file mode 100644 index 0000000..f2f49f2 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresItemCatalogRepository.java @@ -0,0 +1,101 @@ +package com.loremind.infrastructure.persistence.postgres; + +import com.loremind.domain.campaigncontext.CatalogItem; +import com.loremind.domain.campaigncontext.ItemCatalog; +import com.loremind.domain.campaigncontext.ports.ItemCatalogRepository; +import com.loremind.infrastructure.persistence.entity.CatalogItemJpaEntity; +import com.loremind.infrastructure.persistence.entity.ItemCatalogJpaEntity; +import com.loremind.infrastructure.persistence.jpa.ItemCatalogJpaRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Repository +public class PostgresItemCatalogRepository implements ItemCatalogRepository { + + private final ItemCatalogJpaRepository jpaRepository; + + public PostgresItemCatalogRepository(ItemCatalogJpaRepository jpaRepository) { + this.jpaRepository = jpaRepository; + } + + @Override + @Transactional + public ItemCatalog save(ItemCatalog catalog) { + ItemCatalogJpaEntity entity = (catalog.getId() != null) + ? jpaRepository.findById(Long.parseLong(catalog.getId())).orElseGet(ItemCatalogJpaEntity::new) + : new ItemCatalogJpaEntity(); + + entity.setName(catalog.getName()); + entity.setDescription(catalog.getDescription()); + entity.setIcon(catalog.getIcon()); + entity.setCampaignId(Long.parseLong(catalog.getCampaignId())); + entity.setOrder(catalog.getOrder()); + + // Remplacement en bloc des objets (orphanRemoval supprime les anciens). + entity.getItems().clear(); + int position = 0; + for (CatalogItem it : catalog.getItems()) { + entity.getItems().add(CatalogItemJpaEntity.builder() + .name(it.getName()) + .price(it.getPrice()) + .category(it.getCategory()) + .description(it.getDescription()) + .position(position++) + .catalog(entity) + .build()); + } + + return toDomainEntity(jpaRepository.save(entity)); + } + + @Override + @Transactional(readOnly = true) + public Optional findById(String id) { + return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity); + } + + @Override + @Transactional(readOnly = true) + public List findByCampaignId(String campaignId) { + return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream() + .map(this::toDomainEntity) + .collect(Collectors.toList()); + } + + @Override + public void deleteById(String id) { + jpaRepository.deleteById(Long.parseLong(id)); + } + + @Override + public boolean existsById(String id) { + return jpaRepository.existsById(Long.parseLong(id)); + } + + private ItemCatalog toDomainEntity(ItemCatalogJpaEntity e) { + List items = e.getItems().stream() + .map(c -> CatalogItem.builder() + .name(c.getName()) + .price(c.getPrice()) + .category(c.getCategory()) + .description(c.getDescription()) + .build()) + .collect(Collectors.toCollection(ArrayList::new)); + return ItemCatalog.builder() + .id(e.getId().toString()) + .name(e.getName()) + .description(e.getDescription()) + .icon(e.getIcon()) + .campaignId(e.getCampaignId().toString()) + .order(e.getOrder()) + .items(items) + .createdAt(e.getCreatedAt()) + .updatedAt(e.getUpdatedAt()) + .build(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/ItemCatalogController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/ItemCatalogController.java new file mode 100644 index 0000000..941ca95 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/ItemCatalogController.java @@ -0,0 +1,84 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.application.campaigncontext.ItemCatalogService; +import com.loremind.domain.campaigncontext.ItemCatalog; +import com.loremind.domain.campaigncontext.ports.ItemCatalogGenerationException; +import com.loremind.infrastructure.web.dto.campaigncontext.ItemCatalogDTO; +import com.loremind.infrastructure.web.mapper.ItemCatalogMapper; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/item-catalogs") +public class ItemCatalogController { + + private final ItemCatalogService service; + private final ItemCatalogMapper mapper; + + public ItemCatalogController(ItemCatalogService service, ItemCatalogMapper mapper) { + this.service = service; + this.mapper = mapper; + } + + @PostMapping + public ResponseEntity create(@RequestBody ItemCatalogDTO dto) { + ItemCatalog created = service.createCatalog(toData(dto, null)); + return ResponseEntity.ok(mapper.toDTO(created)); + } + + @GetMapping("/{id}") + public ResponseEntity getById(@PathVariable String id) { + return service.getCatalogById(id) + .map(c -> ResponseEntity.ok(mapper.toDTO(c))) + .orElse(ResponseEntity.notFound().build()); + } + + @GetMapping("/campaign/{campaignId}") + public ResponseEntity> getByCampaign(@PathVariable String campaignId) { + List dtos = service.getCatalogsByCampaignId(campaignId).stream() + .map(mapper::toDTO) + .collect(Collectors.toList()); + return ResponseEntity.ok(dtos); + } + + @PutMapping("/{id}") + public ResponseEntity update(@PathVariable String id, @RequestBody ItemCatalogDTO dto) { + ItemCatalog updated = service.updateCatalog(id, toData(dto, dto.getOrder())); + return ResponseEntity.ok(mapper.toDTO(updated)); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable String id) { + service.deleteCatalog(id); + return ResponseEntity.noContent().build(); + } + + /** Génère une PROPOSITION de catalogue via l'IA (non persistée) — l'UI préremplit le formulaire. */ + @PostMapping("/generate") + public ResponseEntity generate(@RequestBody GenerateRequest req) { + try { + ItemCatalog proposal = service.generateProposal(req.campaignId(), req.description()); + return ResponseEntity.ok(mapper.toDTO(proposal)); + } catch (ItemCatalogGenerationException e) { + throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, e.getMessage(), e); + } + } + + private ItemCatalogService.CatalogData toData(ItemCatalogDTO dto, Integer order) { + return new ItemCatalogService.CatalogData( + dto.getName(), + dto.getDescription(), + dto.getIcon(), + mapper.toDomainItems(dto.getItems()), + dto.getCampaignId(), + order + ); + } + + public record GenerateRequest(String campaignId, String description) {} +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CatalogItemDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CatalogItemDTO.java new file mode 100644 index 0000000..55371c2 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CatalogItemDTO.java @@ -0,0 +1,14 @@ +package com.loremind.infrastructure.web.dto.campaigncontext; + +import lombok.Data; + +/** + * DTO d'un objet de catalogue. + */ +@Data +public class CatalogItemDTO { + private String name; + private String price; + private String category; + private String description; +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/ItemCatalogDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/ItemCatalogDTO.java new file mode 100644 index 0000000..fcd8ca9 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/ItemCatalogDTO.java @@ -0,0 +1,20 @@ +package com.loremind.infrastructure.web.dto.campaigncontext; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * DTO d'un catalogue d'objets (avec ses objets). + */ +@Data +public class ItemCatalogDTO { + private String id; + private String name; + private String description; + private String icon; + private String campaignId; + private int order; + private List items = new ArrayList<>(); +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/ItemCatalogMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/ItemCatalogMapper.java new file mode 100644 index 0000000..89b43fe --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/ItemCatalogMapper.java @@ -0,0 +1,50 @@ +package com.loremind.infrastructure.web.mapper; + +import com.loremind.domain.campaigncontext.CatalogItem; +import com.loremind.domain.campaigncontext.ItemCatalog; +import com.loremind.infrastructure.web.dto.campaigncontext.CatalogItemDTO; +import com.loremind.infrastructure.web.dto.campaigncontext.ItemCatalogDTO; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.stream.Collectors; + +@Component +public class ItemCatalogMapper { + + public ItemCatalogDTO toDTO(ItemCatalog c) { + if (c == null) return null; + ItemCatalogDTO dto = new ItemCatalogDTO(); + dto.setId(c.getId()); + dto.setName(c.getName()); + dto.setDescription(c.getDescription()); + dto.setIcon(c.getIcon()); + dto.setCampaignId(c.getCampaignId()); + dto.setOrder(c.getOrder()); + dto.setItems(c.getItems().stream().map(this::toItemDTO).collect(Collectors.toList())); + return dto; + } + + public List toDomainItems(List dtos) { + if (dtos == null) return List.of(); + return dtos.stream().map(this::toDomainItem).collect(Collectors.toList()); + } + + private CatalogItemDTO toItemDTO(CatalogItem i) { + CatalogItemDTO dto = new CatalogItemDTO(); + dto.setName(i.getName()); + dto.setPrice(i.getPrice()); + dto.setCategory(i.getCategory()); + dto.setDescription(i.getDescription()); + return dto; + } + + private CatalogItem toDomainItem(CatalogItemDTO dto) { + return CatalogItem.builder() + .name(dto.getName()) + .price(dto.getPrice()) + .category(dto.getCategory()) + .description(dto.getDescription()) + .build(); + } +} diff --git a/web/src/app/app.routes.ts b/web/src/app/app.routes.ts index 01dcd90..736e5ae 100644 --- a/web/src/app/app.routes.ts +++ b/web/src/app/app.routes.ts @@ -27,6 +27,10 @@ export const routes: Routes = [ { path: 'campaigns/:campaignId/npcs/:npcId', loadComponent: () => import('./campaigns/npc/npc-view/npc-view.component').then(m => m.NpcViewComponent) }, { path: 'campaigns/:campaignId/notebooks', loadComponent: () => import('./campaigns/notebook/notebook-list/notebook-list.component').then(m => m.NotebookListComponent) }, { path: 'campaigns/:campaignId/notebooks/:notebookId', loadComponent: () => import('./campaigns/notebook/notebook-detail/notebook-detail.component').then(m => m.NotebookDetailComponent) }, + { path: 'campaigns/:campaignId/item-catalogs', loadComponent: () => import('./campaigns/item-catalog/item-catalog-list/item-catalog-list.component').then(m => m.ItemCatalogListComponent) }, + { path: 'campaigns/:campaignId/item-catalogs/create', loadComponent: () => import('./campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component').then(m => m.ItemCatalogEditComponent) }, + { path: 'campaigns/:campaignId/item-catalogs/:catalogId/edit', loadComponent: () => import('./campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component').then(m => m.ItemCatalogEditComponent) }, + { path: 'campaigns/:campaignId/item-catalogs/:catalogId', loadComponent: () => import('./campaigns/item-catalog/item-catalog-view/item-catalog-view.component').then(m => m.ItemCatalogViewComponent) }, { path: 'campaigns/:campaignId/random-tables/create', loadComponent: () => import('./campaigns/random-table/random-table-edit/random-table-edit.component').then(m => m.RandomTableEditComponent) }, { path: 'campaigns/:campaignId/random-tables/:tableId/edit', loadComponent: () => import('./campaigns/random-table/random-table-edit/random-table-edit.component').then(m => m.RandomTableEditComponent) }, { path: 'campaigns/:campaignId/random-tables/:tableId', loadComponent: () => import('./campaigns/random-table/random-table-view/random-table-view.component').then(m => m.RandomTableViewComponent) }, diff --git a/web/src/app/campaigns/campaign-tree.helper.ts b/web/src/app/campaigns/campaign-tree.helper.ts index b2b2f24..47f948f 100644 --- a/web/src/app/campaigns/campaign-tree.helper.ts +++ b/web/src/app/campaigns/campaign-tree.helper.ts @@ -225,6 +225,14 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T route: `/campaigns/${campaignId}/notebooks` }; + // Catalogues d'objets (boutiques, butins…) → page de liste (outil). + const catalogsNode: TreeItem = { + id: 'item-catalogs-root', + label: 'Catalogues d\'objets', + iconKey: 'package', + route: `/campaigns/${campaignId}/item-catalogs` + }; + // Importer un PDF de campagne → arborescence (outil, comme tables & ateliers). const importNode: TreeItem = { id: 'import-pdf-root', @@ -233,7 +241,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T route: `/campaigns/${campaignId}/import` }; - return [...arcNodes, npcsNode, tablesNode, notebooksNode, importNode]; + return [...arcNodes, npcsNode, tablesNode, notebooksNode, catalogsNode, importNode]; } /** diff --git a/web/src/app/campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component.html b/web/src/app/campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component.html new file mode 100644 index 0000000..117965f --- /dev/null +++ b/web/src/app/campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component.html @@ -0,0 +1,68 @@ +
+
+ + + +
+ +

{{ catalogId ? 'Éditer le catalogue' : 'Nouveau catalogue d\'objets' }}

+ +
{{ errorMessage }}
+ +
+ + +
+ +
+ + +
+ + +
+
Générer avec l'IA
+

Décris la boutique/le butin ; l'IA propose les objets (revois-les avant d'enregistrer). Contextualisé avec ta campagne.

+ +
+ + {{ aiError }} +
+
+ +
+

Objets

+ +
+ +
+ Nom + Prix + Catégorie + Description + +
+ +
+ + + + + +
+ +

Aucun objet — clique « Ajouter ».

+
diff --git a/web/src/app/campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component.scss b/web/src/app/campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component.scss new file mode 100644 index 0000000..560ca17 --- /dev/null +++ b/web/src/app/campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component.scss @@ -0,0 +1,87 @@ +.ice-page { max-width: 980px; margin: 0 auto; padding: 1rem 1.5rem 3rem; } + +.ice-toolbar { + display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; + .spacer { flex: 1; } + button { + display: inline-flex; align-items: center; gap: 0.35rem; + padding: 0.4rem 0.8rem; border-radius: 6px; + border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04); + color: inherit; cursor: pointer; font-size: 0.85rem; + &:hover { background: rgba(255,255,255,0.09); } + &:disabled { opacity: 0.5; cursor: default; } + } + .btn-save { background: #667eea; border-color: #667eea; color: #fff; } + .btn-save:hover:not(:disabled) { background: #5568d3; } +} + +h1 { font-size: 1.4rem; margin: 0 0 1rem; } +h2 { font-size: 1rem; margin: 0; } + +.error { + padding: 0.6rem 0.9rem; border-radius: 6px; margin-bottom: 1rem; + background: rgba(224,90,90,0.12); border: 1px solid rgba(224,90,90,0.4); color: #e88; +} + +.form-row { + display: flex; flex-direction: column; gap: 0.3rem; margin-bottom: 1rem; + label { font-size: 0.85rem; color: var(--color-text-muted, #9aa0aa); } + input, textarea { + padding: 0.5rem 0.7rem; border-radius: 6px; + border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.04); + color: inherit; font: inherit; + } +} + +.ai-box { + margin: 1rem 0; padding: 0.85rem 1rem; border-radius: 10px; + background: rgba(168,130,255,0.08); border: 1px solid rgba(168,130,255,0.28); + .ai-title { display: flex; align-items: center; gap: 0.4rem; font-weight: 600; color: #c4a8ff; } + .ai-hint { margin: 0.3rem 0 0.5rem; font-size: 0.8rem; color: var(--color-text-muted, #9aa0aa); } + textarea { + width: 100%; padding: 0.5rem 0.7rem; border-radius: 6px; + border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.04); color: inherit; font: inherit; + } + .ai-actions { display: flex; align-items: center; gap: 0.7rem; margin-top: 0.5rem; } + .btn-ai { + display: inline-flex; align-items: center; gap: 0.4rem; + padding: 0.45rem 0.9rem; border: none; border-radius: 7px; + background: #8a6dff; color: #fff; font-weight: 600; cursor: pointer; + &:hover:not(:disabled) { background: #7a5cf0; } + &:disabled { opacity: 0.55; cursor: default; } + } + .ai-error { color: #e88; font-size: 0.82rem; } +} + +.items-head { + display: flex; align-items: center; justify-content: space-between; margin: 1.25rem 0 0.5rem; + .btn-mini { + display: inline-flex; align-items: center; gap: 0.3rem; + padding: 0.3rem 0.6rem; border-radius: 6px; + border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.04); + color: inherit; cursor: pointer; font-size: 0.8rem; + &:hover { background: rgba(255,255,255,0.09); } + } +} + +.item-row { + display: grid; grid-template-columns: 1.4fr 80px 1fr 1.8fr 36px; + gap: 0.5rem; align-items: center; margin-bottom: 0.4rem; + &.head { + font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em; + color: var(--color-text-muted, #9aa0aa); margin-bottom: 0.3rem; + } + input { + padding: 0.4rem 0.55rem; border-radius: 6px; + border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.04); + color: inherit; font: inherit; width: 100%; + } + .btn-del { + display: inline-flex; align-items: center; justify-content: center; padding: 0.35rem; + border-radius: 6px; border: 1px solid rgba(224,90,90,0.3); + background: rgba(224,90,90,0.08); color: #e88; cursor: pointer; + &:hover { background: rgba(224,90,90,0.18); } + } +} + +.empty-hint { color: var(--color-text-muted, #9aa0aa); font-style: italic; } diff --git a/web/src/app/campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component.ts b/web/src/app/campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component.ts new file mode 100644 index 0000000..e667971 --- /dev/null +++ b/web/src/app/campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component.ts @@ -0,0 +1,152 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { LucideAngularModule, ArrowLeft, Save, Plus, Trash2, Sparkles } from 'lucide-angular'; +import { ItemCatalogService } from '../../../services/item-catalog.service'; +import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; +import { ItemCatalog, CatalogItem, ItemCatalogCreate } from '../../../services/item-catalog.model'; + +/** + * Création/édition d'un catalogue d'objets (boutique, butin…). + * Routes : /campaigns/:campaignId/item-catalogs/create + * /campaigns/:campaignId/item-catalogs/:catalogId/edit + */ +@Component({ + selector: 'app-item-catalog-edit', + standalone: true, + imports: [CommonModule, FormsModule, LucideAngularModule], + templateUrl: './item-catalog-edit.component.html', + styleUrls: ['./item-catalog-edit.component.scss'] +}) +export class ItemCatalogEditComponent implements OnInit { + readonly ArrowLeft = ArrowLeft; + readonly Save = Save; + readonly Plus = Plus; + readonly Trash2 = Trash2; + readonly Sparkles = Sparkles; + + campaignId: string | null = null; + catalogId: string | null = null; + + name = ''; + description = ''; + items: CatalogItem[] = []; + + saving = false; + errorMessage = ''; + + aiPrompt = ''; + generating = false; + aiError = ''; + + constructor( + private route: ActivatedRoute, + private router: Router, + private service: ItemCatalogService, + private campaignSidebar: CampaignSidebarService + ) {} + + ngOnInit(): void { + const params = this.route.snapshot.paramMap; + this.campaignId = params.get('campaignId'); + this.catalogId = params.get('catalogId'); + if (this.campaignId) this.campaignSidebar.show(this.campaignId); + + if (this.catalogId) { + this.service.getById(this.catalogId).subscribe({ + next: (c: ItemCatalog) => { + this.name = c.name; + this.description = c.description ?? ''; + this.items = c.items.map(i => ({ ...i })); + }, + error: () => this.back() + }); + } else { + this.addItem(); + } + } + + addItem(): void { + this.items.push({ name: '', price: '', category: '', description: '' }); + } + + removeItem(i: number): void { + this.items.splice(i, 1); + } + + generateWithAI(): void { + if (!this.campaignId) return; + if (!this.aiPrompt.trim()) { this.aiError = 'Décris le catalogue à générer.'; return; } + this.generating = true; + this.aiError = ''; + this.service.generate(this.campaignId, this.aiPrompt.trim()).subscribe({ + next: (c) => { + this.generating = false; + if (c.name && !this.name.trim()) this.name = c.name; + if (c.description) this.description = c.description; + this.items = (c.items ?? []).map(i => ({ ...i })); + }, + error: (err) => { + this.generating = false; + this.aiError = err?.error?.message || 'Échec de la génération IA. Réessaie ou reformule.'; + } + }); + } + + save(): void { + if (!this.campaignId) return; + if (!this.name.trim()) { this.errorMessage = 'Le nom est requis.'; return; } + this.saving = true; + this.errorMessage = ''; + + const cleanItems = this.items + .filter(i => i.name.trim()) + .map(i => ({ + name: i.name.trim(), + price: i.price?.trim() || undefined, + category: i.category?.trim() || undefined, + description: i.description?.trim() || undefined + })); + + if (this.catalogId) { + const payload: ItemCatalog = { + id: this.catalogId, + name: this.name.trim(), + description: this.description.trim() || undefined, + campaignId: this.campaignId, + items: cleanItems + }; + this.service.update(this.catalogId, payload).subscribe({ + next: () => this.goToView(this.catalogId!), + error: (e) => this.fail(e) + }); + } else { + const payload: ItemCatalogCreate = { + name: this.name.trim(), + description: this.description.trim() || undefined, + campaignId: this.campaignId, + items: cleanItems + }; + this.service.create(payload).subscribe({ + next: (c) => this.goToView(c.id!), + error: (e) => this.fail(e) + }); + } + } + + private goToView(id: string): void { + this.saving = false; + this.router.navigate(['/campaigns', this.campaignId, 'item-catalogs', id]); + } + + private fail(err: unknown): void { + this.saving = false; + this.errorMessage = 'Échec de l\'enregistrement.'; + console.error('ItemCatalog save failed', err); + } + + back(): void { + this.router.navigate(this.campaignId ? ['/campaigns', this.campaignId] : ['/campaigns']); + } +} diff --git a/web/src/app/campaigns/item-catalog/item-catalog-list/item-catalog-list.component.html b/web/src/app/campaigns/item-catalog/item-catalog-list/item-catalog-list.component.html new file mode 100644 index 0000000..2efcb73 --- /dev/null +++ b/web/src/app/campaigns/item-catalog/item-catalog-list/item-catalog-list.component.html @@ -0,0 +1,31 @@ +
+
+ + + +
+ +
+

Catalogues d'objets

+

+ Boutiques, butins, trésors… à remplir à la main ou via l'IA. Consultables en session + quand les joueurs visitent une échoppe. +

+
+ +
+

Aucun catalogue pour l'instant.

+ +
+
diff --git a/web/src/app/campaigns/item-catalog/item-catalog-list/item-catalog-list.component.scss b/web/src/app/campaigns/item-catalog/item-catalog-list/item-catalog-list.component.scss new file mode 100644 index 0000000..8487c46 --- /dev/null +++ b/web/src/app/campaigns/item-catalog/item-catalog-list/item-catalog-list.component.scss @@ -0,0 +1,36 @@ +.icl-page { max-width: 760px; margin: 0 auto; padding: 1rem 1.5rem 3rem; } + +.icl-toolbar { + display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; + .spacer { flex: 1; } + button { + display: inline-flex; align-items: center; gap: 0.35rem; + padding: 0.4rem 0.8rem; border-radius: 6px; + border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04); + color: inherit; cursor: pointer; font-size: 0.85rem; + &:hover { background: rgba(255,255,255,0.09); } + } + .btn-create { background: #667eea; border-color: #667eea; color: #fff; } + .btn-create:hover { background: #5568d3; } +} + +.icl-header { + margin-bottom: 1.25rem; + h1 { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.35rem; font-size: 1.5rem; } + .icl-hint { margin: 0; color: var(--color-text-muted, #9aa0aa); font-size: 0.9rem; } +} + +.icl-list { display: flex; flex-direction: column; gap: 0.4rem; } +.empty { color: var(--color-text-muted, #9aa0aa); font-style: italic; } + +.icl-item { + display: flex; align-items: center; gap: 0.55rem; + padding: 0.7rem 0.85rem; border-radius: 8px; + border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); + color: inherit; cursor: pointer; text-align: left; + &:hover { background: rgba(255,255,255,0.07); } + .icl-item-name { flex: 1; font-weight: 500; } + .icl-item-count { font-size: 0.78rem; color: var(--color-text-muted, #9aa0aa); } + .icl-del { display: inline-flex; padding: 0.25rem; border-radius: 5px; color: #e88; + &:hover { background: rgba(224,90,90,0.15); } } +} diff --git a/web/src/app/campaigns/item-catalog/item-catalog-list/item-catalog-list.component.ts b/web/src/app/campaigns/item-catalog/item-catalog-list/item-catalog-list.component.ts new file mode 100644 index 0000000..57da902 --- /dev/null +++ b/web/src/app/campaigns/item-catalog/item-catalog-list/item-catalog-list.component.ts @@ -0,0 +1,77 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; +import { LucideAngularModule, ArrowLeft, Plus, Trash2, Package } from 'lucide-angular'; +import { ItemCatalogService } from '../../../services/item-catalog.service'; +import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; +import { ItemCatalog } from '../../../services/item-catalog.model'; +import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service'; + +/** + * Liste des catalogues d'objets d'une campagne + création. + * Route : /campaigns/:campaignId/item-catalogs + */ +@Component({ + selector: 'app-item-catalog-list', + standalone: true, + imports: [CommonModule, LucideAngularModule], + templateUrl: './item-catalog-list.component.html', + styleUrls: ['./item-catalog-list.component.scss'] +}) +export class ItemCatalogListComponent implements OnInit { + readonly ArrowLeft = ArrowLeft; + readonly Plus = Plus; + readonly Trash2 = Trash2; + readonly Package = Package; + + campaignId = ''; + catalogs: ItemCatalog[] = []; + + constructor( + private route: ActivatedRoute, + private router: Router, + private service: ItemCatalogService, + private campaignSidebar: CampaignSidebarService, + private confirmDialog: ConfirmDialogService + ) {} + + ngOnInit(): void { + this.campaignId = this.route.snapshot.paramMap.get('campaignId') ?? ''; + if (this.campaignId) { + this.campaignSidebar.show(this.campaignId); + this.load(); + } + } + + load(): void { + this.service.getByCampaign(this.campaignId).subscribe({ + next: (list) => this.catalogs = list, + error: () => this.catalogs = [] + }); + } + + create(): void { + this.router.navigate(['/campaigns', this.campaignId, 'item-catalogs', 'create']); + } + + open(c: ItemCatalog): void { + this.router.navigate(['/campaigns', this.campaignId, 'item-catalogs', c.id]); + } + + remove(c: ItemCatalog, ev: Event): void { + ev.stopPropagation(); + this.confirmDialog.confirm({ + title: 'Supprimer le catalogue', + message: `Supprimer « ${c.name} » ?`, + confirmLabel: 'Supprimer', + variant: 'danger' + }).then(ok => { + if (!ok) return; + this.service.delete(c.id!).subscribe(() => this.load()); + }); + } + + back(): void { + this.router.navigate(['/campaigns', this.campaignId]); + } +} diff --git a/web/src/app/campaigns/item-catalog/item-catalog-view/item-catalog-view.component.html b/web/src/app/campaigns/item-catalog/item-catalog-view/item-catalog-view.component.html new file mode 100644 index 0000000..fc0cfab --- /dev/null +++ b/web/src/app/campaigns/item-catalog/item-catalog-view/item-catalog-view.component.html @@ -0,0 +1,33 @@ +
+
+ + + +
+ +
+

{{ catalog.name }}

+

{{ catalog.description }}

+
+ +

+ Aucun objet — édite le catalogue pour en ajouter. +

+ +
+

{{ g.category }}

+ + + + + + + + +
{{ it.name }}{{ it.price }}{{ it.description }}
+
+
diff --git a/web/src/app/campaigns/item-catalog/item-catalog-view/item-catalog-view.component.scss b/web/src/app/campaigns/item-catalog/item-catalog-view/item-catalog-view.component.scss new file mode 100644 index 0000000..da1316a --- /dev/null +++ b/web/src/app/campaigns/item-catalog/item-catalog-view/item-catalog-view.component.scss @@ -0,0 +1,40 @@ +.ic-page { max-width: 880px; margin: 0 auto; padding: 1rem 1.5rem 3rem; } + +.ic-toolbar { + display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; + .spacer { flex: 1; } + button { + display: inline-flex; align-items: center; gap: 0.35rem; + padding: 0.4rem 0.7rem; border-radius: 6px; + border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04); + color: inherit; cursor: pointer; font-size: 0.85rem; + &:hover { background: rgba(255,255,255,0.09); } + } +} + +.ic-header { + margin-bottom: 1.25rem; + h1 { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.35rem; font-size: 1.5rem; } + .ic-desc { margin: 0; color: var(--color-text-muted, #9aa0aa); } +} + +.ic-empty { color: var(--color-text-muted, #9aa0aa); font-style: italic; } + +.ic-group { + margin-bottom: 1.25rem; + h2 { + font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; + color: #c4a8ff; margin: 0 0 0.4rem; padding-bottom: 0.25rem; + border-bottom: 1px solid rgba(168,130,255,0.2); + } + table { width: 100%; border-collapse: collapse; } + td { + padding: 0.45rem 0.6rem; border-bottom: 1px solid rgba(255,255,255,0.06); vertical-align: top; + } + .col-name { font-weight: 500; white-space: nowrap; } + .col-price { + white-space: nowrap; color: #e0c074; font-variant-numeric: tabular-nums; + text-align: right; width: 90px; + } + .col-desc { color: var(--color-text-muted, #cfd3da); font-size: 0.9rem; } +} diff --git a/web/src/app/campaigns/item-catalog/item-catalog-view/item-catalog-view.component.ts b/web/src/app/campaigns/item-catalog/item-catalog-view/item-catalog-view.component.ts new file mode 100644 index 0000000..7dd9d71 --- /dev/null +++ b/web/src/app/campaigns/item-catalog/item-catalog-view/item-catalog-view.component.ts @@ -0,0 +1,73 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; +import { LucideAngularModule, ArrowLeft, Edit3, Package } from 'lucide-angular'; +import { ItemCatalogService } from '../../../services/item-catalog.service'; +import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; +import { ItemCatalog, CatalogItem } from '../../../services/item-catalog.model'; + +interface ItemGroup { category: string; items: CatalogItem[]; } + +/** + * Vue d'un catalogue d'objets : liste (groupée par catégorie). + * Route : /campaigns/:campaignId/item-catalogs/:catalogId + */ +@Component({ + selector: 'app-item-catalog-view', + standalone: true, + imports: [CommonModule, LucideAngularModule], + templateUrl: './item-catalog-view.component.html', + styleUrls: ['./item-catalog-view.component.scss'] +}) +export class ItemCatalogViewComponent implements OnInit { + readonly ArrowLeft = ArrowLeft; + readonly Edit3 = Edit3; + readonly Package = Package; + + campaignId: string | null = null; + catalogId: string | null = null; + catalog: ItemCatalog | null = null; + + constructor( + private route: ActivatedRoute, + private router: Router, + private service: ItemCatalogService, + private campaignSidebar: CampaignSidebarService + ) {} + + ngOnInit(): void { + const params = this.route.snapshot.paramMap; + this.campaignId = params.get('campaignId'); + this.catalogId = params.get('catalogId'); + if (this.catalogId) { + this.service.getById(this.catalogId).subscribe({ + next: c => this.catalog = c, + error: () => this.back() + }); + } + if (this.campaignId) this.campaignSidebar.show(this.campaignId); + } + + /** Objets groupés par catégorie (non catégorisés en dernier). */ + get groups(): ItemGroup[] { + const map = new Map(); + for (const it of this.catalog?.items ?? []) { + const cat = (it.category ?? '').trim() || '—'; + if (!map.has(cat)) map.set(cat, []); + map.get(cat)!.push(it); + } + return [...map.entries()] + .sort(([a], [b]) => (a === '—' ? 1 : b === '—' ? -1 : a.localeCompare(b, 'fr'))) + .map(([category, items]) => ({ category, items })); + } + + edit(): void { + if (this.campaignId && this.catalogId) { + this.router.navigate(['/campaigns', this.campaignId, 'item-catalogs', this.catalogId, 'edit']); + } + } + + back(): void { + this.router.navigate(this.campaignId ? ['/campaigns', this.campaignId] : ['/campaigns']); + } +} diff --git a/web/src/app/lore/lore-icons.ts b/web/src/app/lore/lore-icons.ts index 29f5654..fefbfb3 100644 --- a/web/src/app/lore/lore-icons.ts +++ b/web/src/app/lore/lore-icons.ts @@ -2,7 +2,7 @@ import { Folder, Users, Swords, MapPin, Shield, Crown, Skull, Gem, BookOpen, Scroll, Wand2, Sparkles, TreePine, Mountain, - Ship, Flame, Star, Moon, Key, Globe, Compass, Dices, FileUp, LucideIconData + Ship, Flame, Star, Moon, Key, Globe, Compass, Dices, FileUp, Package, LucideIconData } from 'lucide-angular'; import { CAMPAIGN_ICON_OPTIONS } from '../campaigns/campaign-icons'; @@ -44,6 +44,7 @@ export const LORE_ICON_OPTIONS: IconOption[] = [ { key: 'compass', icon: Compass }, { key: 'dice', icon: Dices }, { key: 'file-up', icon: FileUp }, + { key: 'package', icon: Package }, ]; /** Icône par défaut pour un dossier sans icône. */ diff --git a/web/src/app/services/item-catalog.model.ts b/web/src/app/services/item-catalog.model.ts new file mode 100644 index 0000000..43773c9 --- /dev/null +++ b/web/src/app/services/item-catalog.model.ts @@ -0,0 +1,26 @@ +/** Reflet de l'ItemCatalogDTO côté Core. Un catalogue d'objets (boutique, butin…). */ + +export interface CatalogItem { + name: string; + price?: string; + category?: string; + description?: string; +} + +export interface ItemCatalog { + id?: string; + name: string; + description?: string; + icon?: string; + campaignId: string; + order?: number; + items: CatalogItem[]; +} + +export interface ItemCatalogCreate { + name: string; + description?: string; + icon?: string; + campaignId: string; + items: CatalogItem[]; +} diff --git a/web/src/app/services/item-catalog.service.ts b/web/src/app/services/item-catalog.service.ts new file mode 100644 index 0000000..dc28c17 --- /dev/null +++ b/web/src/app/services/item-catalog.service.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { ItemCatalog, ItemCatalogCreate } from './item-catalog.model'; + +/** + * CRUD des catalogues d'objets (campagne) + génération IA. Mirroir de RandomTableService. + */ +@Injectable({ providedIn: 'root' }) +export class ItemCatalogService { + private apiUrl = '/api/item-catalogs'; + + constructor(private http: HttpClient) {} + + getByCampaign(campaignId: string): Observable { + return this.http.get(`${this.apiUrl}/campaign/${campaignId}`); + } + + getById(id: string): Observable { + return this.http.get(`${this.apiUrl}/${id}`); + } + + create(payload: ItemCatalogCreate): Observable { + return this.http.post(this.apiUrl, payload); + } + + update(id: string, payload: ItemCatalog): Observable { + return this.http.put(`${this.apiUrl}/${id}`, payload); + } + + delete(id: string): Observable { + return this.http.delete(`${this.apiUrl}/${id}`); + } + + /** Génère une PROPOSITION de catalogue via l'IA (non persistée) à préremplir. */ + generate(campaignId: string, description: string): Observable { + return this.http.post(`${this.apiUrl}/generate`, { campaignId, description }); + } +} diff --git a/web/src/app/sessions/session-detail/session-detail.component.html b/web/src/app/sessions/session-detail/session-detail.component.html index 363cd07..739f71a 100644 --- a/web/src/app/sessions/session-detail/session-detail.component.html +++ b/web/src/app/sessions/session-detail/session-detail.component.html @@ -180,7 +180,8 @@ [sessionId]="session.id" [canAddToJournal]="session.active" (rolled)="onDiceRolled($event)" - (aiReplyToJournal)="onAiReplyToJournal($event)"> + (aiReplyToJournal)="onAiReplyToJournal($event)" + (noteToJournal)="onItemNoteToJournal($event)"> diff --git a/web/src/app/sessions/session-detail/session-detail.component.ts b/web/src/app/sessions/session-detail/session-detail.component.ts index 39e54df..89f1445 100644 --- a/web/src/app/sessions/session-detail/session-detail.component.ts +++ b/web/src/app/sessions/session-detail/session-detail.component.ts @@ -265,6 +265,22 @@ export class SessionDetailComponent implements OnInit, OnDestroy { }); } + /** + * Réception d'un objet de catalogue à consigner dans le journal. + * Le panneau fournit déjà une chaîne formatée (🛒 …) : on la sauvegarde + * telle quelle comme entrée NOTE. + */ + onItemNoteToJournal(content: string): void { + if (!this.session || !this.session.active) return; + const trimmed = content.trim(); + if (!trimmed) return; + const input: SessionEntryInput = { type: 'NOTE', content: trimmed }; + this.entryService.createEntry(this.session.id, input).subscribe({ + next: created => this.entries = [created, ...this.entries], + error: () => console.error('Erreur lors de l\'ajout de l\'objet au journal') + }); + } + deleteEntry(entry: SessionEntry): void { if (!this.session) return; const session = this.session; diff --git a/web/src/app/sessions/session-item-catalogs-panel/session-item-catalogs-panel.component.html b/web/src/app/sessions/session-item-catalogs-panel/session-item-catalogs-panel.component.html new file mode 100644 index 0000000..775f5e0 --- /dev/null +++ b/web/src/app/sessions/session-item-catalogs-panel/session-item-catalogs-panel.component.html @@ -0,0 +1,38 @@ +
+

Chargement…

+ + +
+

+ Aucun catalogue d'objets dans cette campagne. +

+ +
+ + +
+ +

{{ selected.name }}

+ +
+
{{ g.category }}
+
+
+ {{ it.name }} + {{ it.price }} +
+
{{ it.description }}
+ +
+
+
+
diff --git a/web/src/app/sessions/session-item-catalogs-panel/session-item-catalogs-panel.component.scss b/web/src/app/sessions/session-item-catalogs-panel/session-item-catalogs-panel.component.scss new file mode 100644 index 0000000..93d537a --- /dev/null +++ b/web/src/app/sessions/session-item-catalogs-panel/session-item-catalogs-panel.component.scss @@ -0,0 +1,48 @@ +.sic-panel { display: flex; flex-direction: column; gap: 0.5rem; padding: 0.25rem; } + +.loading-hint, .empty-hint { + color: var(--color-text-muted, #9aa0aa); font-size: 0.85rem; font-style: italic; padding: 0.5rem 0; +} + +.sic-list { display: flex; flex-direction: column; gap: 0.3rem; } +.sic-item { + display: flex; align-items: center; gap: 0.45rem; width: 100%; + padding: 0.45rem 0.6rem; border-radius: 7px; + border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); + color: inherit; cursor: pointer; text-align: left; + &:hover { background: rgba(255,255,255,0.08); } + .sic-item-name { flex: 1; } + .sic-item-count { + font-size: 0.72rem; color: var(--color-text-muted, #9aa0aa); + background: rgba(255,255,255,0.06); padding: 0.05rem 0.35rem; border-radius: 4px; + } +} + +.sic-detail { display: flex; flex-direction: column; gap: 0.5rem; } +.sic-back { + display: inline-flex; align-items: center; gap: 0.25rem; align-self: flex-start; + padding: 0.2rem 0.4rem; border: none; background: none; + color: var(--color-text-muted, #9aa0aa); cursor: pointer; font-size: 0.8rem; + &:hover { color: inherit; } +} +h4 { margin: 0; font-size: 0.95rem; } + +.sic-group-title { + font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em; + color: #c4a8ff; margin: 0.5rem 0 0.2rem; +} + +.sic-obj { + padding: 0.4rem 0.5rem; border-radius: 7px; background: rgba(255,255,255,0.03); margin-bottom: 0.3rem; + .sic-obj-main { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; } + .sic-obj-name { font-weight: 500; } + .sic-obj-price { color: #e0c074; font-size: 0.82rem; white-space: nowrap; } + .sic-obj-desc { font-size: 0.82rem; color: var(--color-text-muted, #cfd3da); margin-top: 0.15rem; } + .sic-note { + display: inline-flex; align-items: center; gap: 0.25rem; margin-top: 0.35rem; + padding: 0.2rem 0.45rem; border-radius: 5px; font-size: 0.74rem; + border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.05); color: inherit; cursor: pointer; + &:hover:not(:disabled) { background: rgba(255,255,255,0.1); } + &:disabled { opacity: 0.5; cursor: default; } + } +} diff --git a/web/src/app/sessions/session-item-catalogs-panel/session-item-catalogs-panel.component.ts b/web/src/app/sessions/session-item-catalogs-panel/session-item-catalogs-panel.component.ts new file mode 100644 index 0000000..abb0955 --- /dev/null +++ b/web/src/app/sessions/session-item-catalogs-panel/session-item-catalogs-panel.component.ts @@ -0,0 +1,64 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { LucideAngularModule, Package, BookmarkPlus, ChevronLeft } from 'lucide-angular'; +import { catchError, of } from 'rxjs'; +import { ItemCatalogService } from '../../services/item-catalog.service'; +import { ItemCatalog, CatalogItem } from '../../services/item-catalog.model'; + +interface ItemGroup { category: string; items: CatalogItem[]; } + +/** + * Panneau « Objets » du mode jeu : consulter les catalogues (boutiques) de la + * campagne et consigner un objet au journal (ex. « le joueur achète X »). + */ +@Component({ + selector: 'app-session-item-catalogs-panel', + standalone: true, + imports: [CommonModule, LucideAngularModule], + templateUrl: './session-item-catalogs-panel.component.html', + styleUrls: ['./session-item-catalogs-panel.component.scss'] +}) +export class SessionItemCatalogsPanelComponent implements OnInit { + readonly Package = Package; + readonly BookmarkPlus = BookmarkPlus; + readonly ChevronLeft = ChevronLeft; + + @Input() campaignId!: string; + @Input() canAddToJournal = true; + /** Émis pour consigner un objet au journal (entrée NOTE). */ + @Output() noteToJournal = new EventEmitter(); + + catalogs: ItemCatalog[] = []; + loading = false; + selected: ItemCatalog | null = null; + + constructor(private service: ItemCatalogService) {} + + ngOnInit(): void { + if (!this.campaignId) return; + this.loading = true; + this.service.getByCampaign(this.campaignId).pipe(catchError(() => of([] as ItemCatalog[]))) + .subscribe(list => { this.catalogs = list; this.loading = false; }); + } + + select(c: ItemCatalog): void { this.selected = c; } + backToList(): void { this.selected = null; } + + get groups(): ItemGroup[] { + const map = new Map(); + for (const it of this.selected?.items ?? []) { + const cat = (it.category ?? '').trim() || '—'; + if (!map.has(cat)) map.set(cat, []); + map.get(cat)!.push(it); + } + return [...map.entries()] + .sort(([a], [b]) => (a === '—' ? 1 : b === '—' ? -1 : a.localeCompare(b, 'fr'))) + .map(([category, items]) => ({ category, items })); + } + + note(it: CatalogItem): void { + if (!this.canAddToJournal || !this.selected) return; + const price = it.price ? ` (${it.price})` : ''; + this.noteToJournal.emit(`🛒 ${this.selected.name} — ${it.name}${price}`); + } +} diff --git a/web/src/app/sessions/session-reference-panel/session-reference-panel.component.html b/web/src/app/sessions/session-reference-panel/session-reference-panel.component.html index 0a09004..ead2338 100644 --- a/web/src/app/sessions/session-reference-panel/session-reference-panel.component.html +++ b/web/src/app/sessions/session-reference-panel/session-reference-panel.component.html @@ -22,6 +22,13 @@ Tables +