Compare commits
2 Commits
v0.11.2-be
...
v0.11.3-be
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c7dbff6a0 | |||
| 833280c784 |
@@ -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):
|
||||
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]]:
|
||||
|
||||
@@ -66,7 +66,7 @@ from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor
|
||||
app = FastAPI(
|
||||
title="LoreMind Brain",
|
||||
description="Backend IA pour la génération de contenu narratif.",
|
||||
version="0.11.2-beta",
|
||||
version="0.11.3-beta",
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -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é ----------
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<groupId>com.loremind</groupId>
|
||||
<artifactId>loremind-core</artifactId>
|
||||
<version>0.11.2-beta</version>
|
||||
<version>0.11.3-beta</version>
|
||||
<name>LoreMind Core</name>
|
||||
<description>Backend Core - Architecture Hexagonale</description>
|
||||
|
||||
|
||||
@@ -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<CatalogItem> 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<ItemCatalog> getCatalogById(String id) {
|
||||
return repository.findById(id);
|
||||
}
|
||||
|
||||
public List<ItemCatalog> 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<CatalogItem> copyItems(List<CatalogItem> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<CatalogItem> items;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public List<CatalogItem> getItems() {
|
||||
if (items == null) items = new ArrayList<>();
|
||||
return items;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<CatalogItem> 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);
|
||||
}
|
||||
@@ -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<ItemCatalog> findById(String id);
|
||||
|
||||
List<ItemCatalog> findByCampaignId(String campaignId);
|
||||
|
||||
void deleteById(String id);
|
||||
|
||||
boolean existsById(String id);
|
||||
}
|
||||
@@ -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<String, Object> 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<Map<String, Object>> entity = new HttpEntity<>(req, headers);
|
||||
|
||||
Map<String, Object> 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<CatalogItem> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<CatalogItemJpaEntity> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<ItemCatalogJpaEntity, Long> {
|
||||
|
||||
List<ItemCatalogJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
|
||||
}
|
||||
@@ -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<ItemCatalog> findById(String id) {
|
||||
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<ItemCatalog> 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<CatalogItem> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<ItemCatalogDTO> create(@RequestBody ItemCatalogDTO dto) {
|
||||
ItemCatalog created = service.createCatalog(toData(dto, null));
|
||||
return ResponseEntity.ok(mapper.toDTO(created));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<ItemCatalogDTO> getById(@PathVariable String id) {
|
||||
return service.getCatalogById(id)
|
||||
.map(c -> ResponseEntity.ok(mapper.toDTO(c)))
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@GetMapping("/campaign/{campaignId}")
|
||||
public ResponseEntity<List<ItemCatalogDTO>> getByCampaign(@PathVariable String campaignId) {
|
||||
List<ItemCatalogDTO> dtos = service.getCatalogsByCampaignId(campaignId).stream()
|
||||
.map(mapper::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
return ResponseEntity.ok(dtos);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<ItemCatalogDTO> 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<Void> 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<ItemCatalogDTO> 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) {}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<CatalogItemDTO> items = new ArrayList<>();
|
||||
}
|
||||
@@ -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<CatalogItem> toDomainItems(List<CatalogItemDTO> 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();
|
||||
}
|
||||
}
|
||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "loremind-web",
|
||||
"version": "0.11.2-beta",
|
||||
"version": "0.11.3-beta",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "loremind-web",
|
||||
"version": "0.11.2-beta",
|
||||
"version": "0.11.3-beta",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^17.0.0",
|
||||
"@angular/common": "^17.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "loremind-web",
|
||||
"version": "0.11.2-beta",
|
||||
"version": "0.11.3-beta",
|
||||
"description": "LoreMind Frontend - Angular",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<div class="ice-page">
|
||||
<div class="ice-toolbar">
|
||||
<button class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Retour
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn-save" (click)="save()" [disabled]="saving">
|
||||
<lucide-icon [img]="Save" [size]="14"></lucide-icon>
|
||||
{{ saving ? 'Enregistrement…' : 'Enregistrer' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h1>{{ catalogId ? 'Éditer le catalogue' : 'Nouveau catalogue d\'objets' }}</h1>
|
||||
|
||||
<div class="error" *ngIf="errorMessage">{{ errorMessage }}</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="ic-name">Nom *</label>
|
||||
<input id="ic-name" type="text" [(ngModel)]="name" placeholder="Ex: Échoppe de l'armurier nain">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="ic-desc">Description</label>
|
||||
<textarea id="ic-desc" rows="2" [(ngModel)]="description" placeholder="À quoi sert ce catalogue / cette boutique ?"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Génération IA -->
|
||||
<div class="ai-box">
|
||||
<div class="ai-title"><lucide-icon [img]="Sparkles" [size]="15"></lucide-icon> Générer avec l'IA</div>
|
||||
<p class="ai-hint">Décris la boutique/le butin ; l'IA propose les objets (revois-les avant d'enregistrer). Contextualisé avec ta campagne.</p>
|
||||
<textarea rows="2" [(ngModel)]="aiPrompt"
|
||||
placeholder="Ex: boutique d'alchimiste dans une cité portuaire, objets exotiques"></textarea>
|
||||
<div class="ai-actions">
|
||||
<button class="btn-ai" (click)="generateWithAI()" [disabled]="generating">
|
||||
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||
{{ generating ? 'Génération…' : 'Générer' }}
|
||||
</button>
|
||||
<span class="ai-error" *ngIf="aiError">{{ aiError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="items-head">
|
||||
<h2>Objets</h2>
|
||||
<button class="btn-mini" (click)="addItem()">
|
||||
<lucide-icon [img]="Plus" [size]="13"></lucide-icon> Ajouter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="item-row head">
|
||||
<span class="c-name">Nom</span>
|
||||
<span class="c-price">Prix</span>
|
||||
<span class="c-cat">Catégorie</span>
|
||||
<span class="c-desc">Description</span>
|
||||
<span class="c-del"></span>
|
||||
</div>
|
||||
|
||||
<div class="item-row" *ngFor="let it of items; let i = index">
|
||||
<input class="c-name" type="text" [(ngModel)]="it.name" placeholder="Objet">
|
||||
<input class="c-price" type="text" [(ngModel)]="it.price" placeholder="50 po">
|
||||
<input class="c-cat" type="text" [(ngModel)]="it.category" placeholder="Armes">
|
||||
<input class="c-desc" type="text" [(ngModel)]="it.description" placeholder="Effet / détails">
|
||||
<button class="c-del btn-del" (click)="removeItem(i)" title="Supprimer">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="empty-hint" *ngIf="items.length === 0">Aucun objet — clique « Ajouter ».</p>
|
||||
</div>
|
||||
@@ -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; }
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<div class="icl-page">
|
||||
<div class="icl-toolbar">
|
||||
<button class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Retour
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn-create" (click)="create()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> Nouveau catalogue
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<header class="icl-header">
|
||||
<h1><lucide-icon [img]="Package" [size]="22"></lucide-icon> Catalogues d'objets</h1>
|
||||
<p class="icl-hint">
|
||||
Boutiques, butins, trésors… à remplir à la main ou via l'IA. Consultables en session
|
||||
quand les joueurs visitent une échoppe.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="icl-list">
|
||||
<p class="empty" *ngIf="catalogs.length === 0">Aucun catalogue pour l'instant.</p>
|
||||
<button class="icl-item" *ngFor="let c of catalogs" (click)="open(c)">
|
||||
<lucide-icon [img]="Package" [size]="16"></lucide-icon>
|
||||
<span class="icl-item-name">{{ c.name }}</span>
|
||||
<span class="icl-item-count">{{ c.items.length }} objet(s)</span>
|
||||
<span class="icl-del" (click)="remove(c, $event)" title="Supprimer">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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); } }
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<div class="ic-page" *ngIf="catalog">
|
||||
<div class="ic-toolbar">
|
||||
<button class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Retour
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn-edit" (click)="edit()">
|
||||
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon> Éditer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<header class="ic-header">
|
||||
<h1><lucide-icon [img]="Package" [size]="22"></lucide-icon> {{ catalog.name }}</h1>
|
||||
<p class="ic-desc" *ngIf="catalog.description">{{ catalog.description }}</p>
|
||||
</header>
|
||||
|
||||
<p class="ic-empty" *ngIf="catalog.items.length === 0">
|
||||
Aucun objet — édite le catalogue pour en ajouter.
|
||||
</p>
|
||||
|
||||
<section class="ic-group" *ngFor="let g of groups">
|
||||
<h2 *ngIf="g.category !== '—'">{{ g.category }}</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr *ngFor="let it of g.items">
|
||||
<td class="col-name">{{ it.name }}</td>
|
||||
<td class="col-price">{{ it.price }}</td>
|
||||
<td class="col-desc">{{ it.description }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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<string, CatalogItem[]>();
|
||||
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']);
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
26
web/src/app/services/item-catalog.model.ts
Normal file
26
web/src/app/services/item-catalog.model.ts
Normal file
@@ -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[];
|
||||
}
|
||||
39
web/src/app/services/item-catalog.service.ts
Normal file
39
web/src/app/services/item-catalog.service.ts
Normal file
@@ -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<ItemCatalog[]> {
|
||||
return this.http.get<ItemCatalog[]>(`${this.apiUrl}/campaign/${campaignId}`);
|
||||
}
|
||||
|
||||
getById(id: string): Observable<ItemCatalog> {
|
||||
return this.http.get<ItemCatalog>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
create(payload: ItemCatalogCreate): Observable<ItemCatalog> {
|
||||
return this.http.post<ItemCatalog>(this.apiUrl, payload);
|
||||
}
|
||||
|
||||
update(id: string, payload: ItemCatalog): Observable<ItemCatalog> {
|
||||
return this.http.put<ItemCatalog>(`${this.apiUrl}/${id}`, payload);
|
||||
}
|
||||
|
||||
delete(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
/** Génère une PROPOSITION de catalogue via l'IA (non persistée) à préremplir. */
|
||||
generate(campaignId: string, description: string): Observable<ItemCatalog> {
|
||||
return this.http.post<ItemCatalog>(`${this.apiUrl}/generate`, { campaignId, description });
|
||||
}
|
||||
}
|
||||
@@ -180,7 +180,8 @@
|
||||
[sessionId]="session.id"
|
||||
[canAddToJournal]="session.active"
|
||||
(rolled)="onDiceRolled($event)"
|
||||
(aiReplyToJournal)="onAiReplyToJournal($event)">
|
||||
(aiReplyToJournal)="onAiReplyToJournal($event)"
|
||||
(noteToJournal)="onItemNoteToJournal($event)">
|
||||
</app-session-reference-panel>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<div class="sic-panel">
|
||||
<p class="loading-hint" *ngIf="loading">Chargement…</p>
|
||||
|
||||
<!-- Liste des catalogues -->
|
||||
<div *ngIf="!loading && !selected" class="sic-list">
|
||||
<p class="empty-hint" *ngIf="catalogs.length === 0">
|
||||
Aucun catalogue d'objets dans cette campagne.
|
||||
</p>
|
||||
<button *ngFor="let c of catalogs" type="button" class="sic-item" (click)="select(c)">
|
||||
<lucide-icon [img]="Package" [size]="13"></lucide-icon>
|
||||
<span class="sic-item-name">{{ c.name }}</span>
|
||||
<span class="sic-item-count">{{ c.items.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Catalogue sélectionné -->
|
||||
<div *ngIf="selected" class="sic-detail">
|
||||
<button type="button" class="sic-back" (click)="backToList()">
|
||||
<lucide-icon [img]="ChevronLeft" [size]="13"></lucide-icon> Catalogues
|
||||
</button>
|
||||
<h4>{{ selected.name }}</h4>
|
||||
|
||||
<div class="sic-group" *ngFor="let g of groups">
|
||||
<div class="sic-group-title" *ngIf="g.category !== '—'">{{ g.category }}</div>
|
||||
<div class="sic-obj" *ngFor="let it of g.items">
|
||||
<div class="sic-obj-main">
|
||||
<span class="sic-obj-name">{{ it.name }}</span>
|
||||
<span class="sic-obj-price" *ngIf="it.price">{{ it.price }}</span>
|
||||
</div>
|
||||
<div class="sic-obj-desc" *ngIf="it.description">{{ it.description }}</div>
|
||||
<button type="button" class="sic-note" (click)="note(it)" [disabled]="!canAddToJournal"
|
||||
title="Consigner au journal (ex. achat)">
|
||||
<lucide-icon [img]="BookmarkPlus" [size]="12"></lucide-icon> Journal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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<string>();
|
||||
|
||||
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<string, CatalogItem[]>();
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,13 @@
|
||||
<lucide-icon [img]="Table2" [size]="14"></lucide-icon>
|
||||
Tables
|
||||
</button>
|
||||
<button type="button"
|
||||
class="ref-tab"
|
||||
[class.ref-tab--active]="activeTab === 'objects'"
|
||||
(click)="selectTab('objects')">
|
||||
<lucide-icon [img]="Package" [size]="14"></lucide-icon>
|
||||
Objets
|
||||
</button>
|
||||
<button type="button"
|
||||
class="ref-tab"
|
||||
[class.ref-tab--active]="activeTab === 'characters'"
|
||||
@@ -64,6 +71,14 @@
|
||||
(aiReplyToJournal)="onAiSaveToJournal($event)">
|
||||
</app-session-random-tables-panel>
|
||||
|
||||
<!-- ====== Catalogues d'objets ====== -->
|
||||
<app-session-item-catalogs-panel
|
||||
*ngIf="activeTab === 'objects'"
|
||||
[campaignId]="campaignId"
|
||||
[canAddToJournal]="canAddToJournal"
|
||||
(noteToJournal)="onItemNote($event)">
|
||||
</app-session-item-catalogs-panel>
|
||||
|
||||
<!-- ====== Personnages (PJ + PNJ) ====== -->
|
||||
<div *ngIf="activeTab === 'characters'" class="ref-list">
|
||||
<p class="loading-hint" *ngIf="loadingChars">Chargement…</p>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { LucideAngularModule, User, Drama, Swords, Dices, ExternalLink, Sparkles, Table2 } from 'lucide-angular';
|
||||
import { LucideAngularModule, User, Drama, Swords, Dices, ExternalLink, Sparkles, Table2, Package } from 'lucide-angular';
|
||||
import { catchError, of } from 'rxjs';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
@@ -14,8 +14,9 @@ import {
|
||||
} from '../session-dice-panel/session-dice-panel.component';
|
||||
import { SessionAiChatPanelComponent } from '../session-ai-chat-panel/session-ai-chat-panel.component';
|
||||
import { SessionRandomTablesPanelComponent } from '../session-random-tables-panel/session-random-tables-panel.component';
|
||||
import { SessionItemCatalogsPanelComponent } from '../session-item-catalogs-panel/session-item-catalogs-panel.component';
|
||||
|
||||
type TabId = 'dice' | 'tables' | 'characters' | 'scenes' | 'ai';
|
||||
type TabId = 'dice' | 'tables' | 'objects' | 'characters' | 'scenes' | 'ai';
|
||||
|
||||
/**
|
||||
* Panneau latéral du mode jeu : référence rapide en lecture seule.
|
||||
@@ -30,7 +31,7 @@ type TabId = 'dice' | 'tables' | 'characters' | 'scenes' | 'ai';
|
||||
@Component({
|
||||
selector: 'app-session-reference-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule, LucideAngularModule, SessionDicePanelComponent, SessionAiChatPanelComponent, SessionRandomTablesPanelComponent],
|
||||
imports: [CommonModule, LucideAngularModule, SessionDicePanelComponent, SessionAiChatPanelComponent, SessionRandomTablesPanelComponent, SessionItemCatalogsPanelComponent],
|
||||
templateUrl: './session-reference-panel.component.html',
|
||||
styleUrls: ['./session-reference-panel.component.scss']
|
||||
})
|
||||
@@ -42,6 +43,7 @@ export class SessionReferencePanelComponent implements OnChanges {
|
||||
readonly ExternalLink = ExternalLink;
|
||||
readonly Sparkles = Sparkles;
|
||||
readonly Table2 = Table2;
|
||||
readonly Package = Package;
|
||||
|
||||
@Input() campaignId!: string;
|
||||
/** Partie active — nécessaire pour charger les PJ (refonte Playthrough). */
|
||||
@@ -51,6 +53,8 @@ export class SessionReferencePanelComponent implements OnChanges {
|
||||
@Output() rolled = new EventEmitter<DiceRollResult>();
|
||||
/** Émis quand l'IA répond et que le MJ veut sauvegarder la réponse comme entrée. */
|
||||
@Output() aiReplyToJournal = new EventEmitter<string>();
|
||||
/** Émis pour consigner un objet de catalogue au journal (entrée NOTE). */
|
||||
@Output() noteToJournal = new EventEmitter<string>();
|
||||
|
||||
activeTab: TabId = 'dice';
|
||||
|
||||
@@ -143,4 +147,8 @@ export class SessionReferencePanelComponent implements OnChanges {
|
||||
onAiSaveToJournal(content: string): void {
|
||||
this.aiReplyToJournal.emit(content);
|
||||
}
|
||||
|
||||
onItemNote(content: string): void {
|
||||
this.noteToJournal.emit(content);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user