Compare commits
3 Commits
v0.11.2-be
...
341f6a5aae
| Author | SHA1 | Date | |
|---|---|---|---|
| 341f6a5aae | |||
| 6c7dbff6a0 | |||
| 833280c784 |
@@ -41,11 +41,17 @@ QUESTION : {question}
|
|||||||
|
|
||||||
Informations pertinentes (ou « {no_match} ») :"""
|
Informations pertinentes (ou « {no_match} ») :"""
|
||||||
|
|
||||||
_REDUCE_SYSTEM = """Tu réponds à la question d'un MJ à partir de NOTES extraites de
|
_REDUCE_SYSTEM = """Tu es l'assistant-MJ d'un jeu de rôle. Tu réponds à la demande du MJ en
|
||||||
l'ENSEMBLE d'un document source (donc tu as une vue COMPLÈTE, pas un simple extrait).
|
t'appuyant sur TROIS sources : (1) des NOTES extraites de l'ENSEMBLE du document source (vue
|
||||||
Synthétise ces notes en une réponse claire et structurée, cite les pages (« p. X »),
|
complète — mais POSSIBLEMENT VIDE si rien d'utile n'y figure), (2) le contexte de sa CAMPAGNE,
|
||||||
et n'invente rien qui n'y figure pas. Si une CAMPAGNE est fournie ci-dessous, relie ta
|
(3) la conversation ci-dessous.
|
||||||
réponse à sa structure / ses PNJ pour des adaptations cohérentes.
|
|
||||||
|
- 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}
|
{context_block}
|
||||||
--- NOTES EXTRAITES DE TOUT LE DOCUMENT ---
|
--- NOTES EXTRAITES DE TOUT LE DOCUMENT ---
|
||||||
@@ -113,8 +119,21 @@ class NotebookDeepUseCase:
|
|||||||
# dernier message est bien la question courante.
|
# dernier message est bien la question courante.
|
||||||
reduce_messages = messages[-history_limit:] if messages else [ChatMessage(role="user", content=question)]
|
reduce_messages = messages[-history_limit:] if messages else [ChatMessage(role="user", content=question)]
|
||||||
llm_chat: LLMChatProvider = self._llm # type: ignore[assignment]
|
llm_chat: LLMChatProvider = self._llm # type: ignore[assignment]
|
||||||
|
produced = False
|
||||||
async for token in llm_chat.stream_chat(reduce_messages, system_prompt=system_prompt):
|
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"}
|
yield {"type": "done"}
|
||||||
|
|
||||||
def _group(self, chunks: list[dict]) -> list[list[dict]]:
|
def _group(self, chunks: list[dict]) -> list[list[dict]]:
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="LoreMind Brain",
|
title="LoreMind Brain",
|
||||||
description="Backend IA pour la génération de contenu narratif.",
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -1015,6 +1015,80 @@ async def improvise_table_roll(
|
|||||||
return ImproviseRollResponseDTO(narration=raw.strip())
|
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é ----------
|
# --- Notebooks (atelier RAG) : indexation des sources + chat ancré ----------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<groupId>com.loremind</groupId>
|
<groupId>com.loremind</groupId>
|
||||||
<artifactId>loremind-core</artifactId>
|
<artifactId>loremind-core</artifactId>
|
||||||
<version>0.11.2-beta</version>
|
<version>0.11.3-beta</version>
|
||||||
<name>LoreMind Core</name>
|
<name>LoreMind Core</name>
|
||||||
<description>Backend Core - Architecture Hexagonale</description>
|
<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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
# Configuration de test : vraie base PostgreSQL loremind_test.
|
# Configuration de test : vraie base PostgreSQL loremind_test.
|
||||||
#
|
#
|
||||||
# La base `loremind_test` doit exister sur l'instance locale (port 5432).
|
# La base `loremind_test` doit exister sur l'instance locale (port 5432).
|
||||||
# Les credentials sont ceux de `.env` a la racine du projet ; on les duplique
|
# AUCUN credential reel ici (fichier versionne !) : definissez les variables
|
||||||
# ici car les tests Maven ne chargent pas le .env.
|
# d'environnement en local (IDE ou shell) comme en CI :
|
||||||
#
|
|
||||||
# Surchargables via variables d'environnement (CI) :
|
|
||||||
# SPRING_DATASOURCE_URL, SPRING_DATASOURCE_USERNAME, SPRING_DATASOURCE_PASSWORD
|
# SPRING_DATASOURCE_URL, SPRING_DATASOURCE_USERNAME, SPRING_DATASOURCE_PASSWORD
|
||||||
spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/loremind_test}
|
spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/loremind_test}
|
||||||
spring.datasource.username=${SPRING_DATASOURCE_USERNAME:loremind_test}
|
spring.datasource.username=${SPRING_DATASOURCE_USERNAME:loremind_test}
|
||||||
|
|||||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.11.2-beta",
|
"version": "0.11.3-beta",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.11.2-beta",
|
"version": "0.11.3-beta",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^17.0.0",
|
"@angular/animations": "^17.0.0",
|
||||||
"@angular/common": "^17.0.0",
|
"@angular/common": "^17.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.11.2-beta",
|
"version": "0.11.3-beta",
|
||||||
"description": "LoreMind Frontend - Angular",
|
"description": "LoreMind Frontend - Angular",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"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/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', 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/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/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/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) },
|
{ 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`
|
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).
|
// Importer un PDF de campagne → arborescence (outil, comme tables & ateliers).
|
||||||
const importNode: TreeItem = {
|
const importNode: TreeItem = {
|
||||||
id: 'import-pdf-root',
|
id: 'import-pdf-root',
|
||||||
@@ -233,7 +241,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
|||||||
route: `/campaigns/${campaignId}/import`
|
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,
|
Folder,
|
||||||
Users, Swords, MapPin, Shield, Crown, Skull, Gem,
|
Users, Swords, MapPin, Shield, Crown, Skull, Gem,
|
||||||
BookOpen, Scroll, Wand2, Sparkles, TreePine, Mountain,
|
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';
|
} from 'lucide-angular';
|
||||||
import { CAMPAIGN_ICON_OPTIONS } from '../campaigns/campaign-icons';
|
import { CAMPAIGN_ICON_OPTIONS } from '../campaigns/campaign-icons';
|
||||||
|
|
||||||
@@ -44,6 +44,7 @@ export const LORE_ICON_OPTIONS: IconOption[] = [
|
|||||||
{ key: 'compass', icon: Compass },
|
{ key: 'compass', icon: Compass },
|
||||||
{ key: 'dice', icon: Dices },
|
{ key: 'dice', icon: Dices },
|
||||||
{ key: 'file-up', icon: FileUp },
|
{ key: 'file-up', icon: FileUp },
|
||||||
|
{ key: 'package', icon: Package },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Icône par défaut pour un dossier sans icône. */
|
/** 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"
|
[sessionId]="session.id"
|
||||||
[canAddToJournal]="session.active"
|
[canAddToJournal]="session.active"
|
||||||
(rolled)="onDiceRolled($event)"
|
(rolled)="onDiceRolled($event)"
|
||||||
(aiReplyToJournal)="onAiReplyToJournal($event)">
|
(aiReplyToJournal)="onAiReplyToJournal($event)"
|
||||||
|
(noteToJournal)="onItemNoteToJournal($event)">
|
||||||
</app-session-reference-panel>
|
</app-session-reference-panel>
|
||||||
</aside>
|
</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 {
|
deleteEntry(entry: SessionEntry): void {
|
||||||
if (!this.session) return;
|
if (!this.session) return;
|
||||||
const session = this.session;
|
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>
|
<lucide-icon [img]="Table2" [size]="14"></lucide-icon>
|
||||||
Tables
|
Tables
|
||||||
</button>
|
</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"
|
<button type="button"
|
||||||
class="ref-tab"
|
class="ref-tab"
|
||||||
[class.ref-tab--active]="activeTab === 'characters'"
|
[class.ref-tab--active]="activeTab === 'characters'"
|
||||||
@@ -64,6 +71,14 @@
|
|||||||
(aiReplyToJournal)="onAiSaveToJournal($event)">
|
(aiReplyToJournal)="onAiSaveToJournal($event)">
|
||||||
</app-session-random-tables-panel>
|
</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) ====== -->
|
<!-- ====== Personnages (PJ + PNJ) ====== -->
|
||||||
<div *ngIf="activeTab === 'characters'" class="ref-list">
|
<div *ngIf="activeTab === 'characters'" class="ref-list">
|
||||||
<p class="loading-hint" *ngIf="loadingChars">Chargement…</p>
|
<p class="loading-hint" *ngIf="loadingChars">Chargement…</p>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
|
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
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 { catchError, of } from 'rxjs';
|
||||||
import { CampaignService } from '../../services/campaign.service';
|
import { CampaignService } from '../../services/campaign.service';
|
||||||
import { CharacterService } from '../../services/character.service';
|
import { CharacterService } from '../../services/character.service';
|
||||||
@@ -14,8 +14,9 @@ import {
|
|||||||
} from '../session-dice-panel/session-dice-panel.component';
|
} from '../session-dice-panel/session-dice-panel.component';
|
||||||
import { SessionAiChatPanelComponent } from '../session-ai-chat-panel/session-ai-chat-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 { 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.
|
* 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({
|
@Component({
|
||||||
selector: 'app-session-reference-panel',
|
selector: 'app-session-reference-panel',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, LucideAngularModule, SessionDicePanelComponent, SessionAiChatPanelComponent, SessionRandomTablesPanelComponent],
|
imports: [CommonModule, LucideAngularModule, SessionDicePanelComponent, SessionAiChatPanelComponent, SessionRandomTablesPanelComponent, SessionItemCatalogsPanelComponent],
|
||||||
templateUrl: './session-reference-panel.component.html',
|
templateUrl: './session-reference-panel.component.html',
|
||||||
styleUrls: ['./session-reference-panel.component.scss']
|
styleUrls: ['./session-reference-panel.component.scss']
|
||||||
})
|
})
|
||||||
@@ -42,6 +43,7 @@ export class SessionReferencePanelComponent implements OnChanges {
|
|||||||
readonly ExternalLink = ExternalLink;
|
readonly ExternalLink = ExternalLink;
|
||||||
readonly Sparkles = Sparkles;
|
readonly Sparkles = Sparkles;
|
||||||
readonly Table2 = Table2;
|
readonly Table2 = Table2;
|
||||||
|
readonly Package = Package;
|
||||||
|
|
||||||
@Input() campaignId!: string;
|
@Input() campaignId!: string;
|
||||||
/** Partie active — nécessaire pour charger les PJ (refonte Playthrough). */
|
/** Partie active — nécessaire pour charger les PJ (refonte Playthrough). */
|
||||||
@@ -51,6 +53,8 @@ export class SessionReferencePanelComponent implements OnChanges {
|
|||||||
@Output() rolled = new EventEmitter<DiceRollResult>();
|
@Output() rolled = new EventEmitter<DiceRollResult>();
|
||||||
/** Émis quand l'IA répond et que le MJ veut sauvegarder la réponse comme entrée. */
|
/** Émis quand l'IA répond et que le MJ veut sauvegarder la réponse comme entrée. */
|
||||||
@Output() aiReplyToJournal = new EventEmitter<string>();
|
@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';
|
activeTab: TabId = 'dice';
|
||||||
|
|
||||||
@@ -143,4 +147,8 @@ export class SessionReferencePanelComponent implements OnChanges {
|
|||||||
onAiSaveToJournal(content: string): void {
|
onAiSaveToJournal(content: string): void {
|
||||||
this.aiReplyToJournal.emit(content);
|
this.aiReplyToJournal.emit(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onItemNote(content: string): void {
|
||||||
|
this.noteToJournal.emit(content);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user