Compare commits

..

80 Commits

Author SHA1 Message Date
6c7dbff6a0 passage version v0.11.3-beta
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-08 17:01:05 +02:00
833280c784 Amélioration de l'IA pour la partie atelier PDF
Mise en place d'un outil permettant de faire des tableau d'objets pour des boutiques par exemple
2026-06-08 17:00:22 +02:00
70ec1f2fb9 Modification du timeout pour permettre d'analyser de plus gros livres
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-08 14:49:00 +02:00
f638fdd24b Mise à jour d'un test unitaire qui ne passait plus avec la mise à jour précédente
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-08 11:11:50 +02:00
e85ab0e6b1 passage version 0.11.1-beta 2026-06-08 11:05:48 +02:00
ed22d9f29c Résolution d'un bug de switch entre les PNJ lorsqu'on essai de passer directement de l'un à l'autre
Ajout de dossiers pour la partie PNJ pour qu'on puisse les regrouper par cité par exemple et que ce soit plus lisible
2026-06-08 11:05:07 +02:00
edc4434298 Plusieurs gros ajouts :
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
- Possibilité de discuter avec un PDF ; RAG ou analyse approfondie. Enlèvement de l'autre outil PDF de discussion qui analysait d'abord un PDF en proposant directement une intégration sans attendre qu'on pose de question
- Mise en place de l'import directement dans les outils dans la sidebar
- Mise en place d'un outil pour créer des tables aléatoires avec possibilité d'utiliser pendant la partie
- Mise en place d'un outil pour mettre en place des PNJ, scènes, chapitre.... directement à partir de la discussion avec le PDF
- Mise en place RAG avec mistal-embeding ou nomic si on utilise ollama
- Mise en place mistral, google en fournisseurs alternatifs pour l'IA dans le cloud
- version 0.11.0-bêta
2026-06-07 09:52:15 +02:00
5eb15dc449 passage 0.10.4
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-05 16:54:41 +02:00
da5b602f20 Meilleure gestion des erreurs, moins permissif au niveau de la créativité du modèle. 2026-06-05 16:54:28 +02:00
9ea43a1889 Ajout d'open router en fournisseur IA ; ajout de la possibilité de mettre des conditions de déverouillage pour les chapitres optionnels
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-05 00:23:19 +02:00
211e26dae1 changement de version
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-04 14:14:19 +02:00
53065c952b Correction de la configuration nginx pour ne pas bloquer un gros PDF 2026-06-04 14:13:41 +02:00
6d00543a59 Ajout de 2 fonctionnalitées principales : import PDF que ce soit pour les règles ou les campagnes directement.
Some checks failed
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
Fonctionnalité de comparaison PDF / campagne pour faire un mix et demander des conseils à l'IA
2026-06-04 13:55:27 +02:00
091de0daf7 Mise à jour des tests unitaires et passage en v0.9.2-beta en conséquence.
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-03 15:56:26 +02:00
504c4b7b6c Plusieurs ajouts :
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
- Possibilité de configurer des lieux dans une scène : permet de configurer un donjon par exemple avec les pièces, les trésors par pièce, la narration..... Mise en place également de conditions permettant de conditionner le déblocage des quêtes.
- Possibilité de transformé un arc en instance non linéaire afin de faire un hub. Permet de jouer de préparer des campagnes type Dragon of Icespire peak plus facilement.
- Configuration de partie : chaque partie va contenir les séances, ce qui permettra de suivre le déblocage des conditions pour les quêtes.

Passage en 0.9.1-beta
2026-06-03 15:44:48 +02:00
e3fc96a1bc Ajout d'un mode "jeu" (possibilité de lancer des sessions dans une campagne). Cela permet de faire de prendre des notes en live au cours d'une partie et d'avoir plusieurs outils sous la main pour aider le mj :
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
- Possibilité de parler à une IA pour règle de jeu ou élément de lore / campagne au cours d'une partie comme aide mémoire
- Onglet dédié aux personnages de la campagne
- Onglet dédié aux scènes
- Onglet avec dès pour ceux qui souhaitent ;

Possibilité de rajouté une note en tant qu'évènement, jet de dès ou encore action du joueur par exemple. D'autres ajouts seront fait dans le futur (notamment des tables aléatoires pour PNJ en live).
2026-05-20 14:59:26 +02:00
bc05b1536d Correction du bug de switch entre lore / campagne et la sidebar qui ne s'actualise pas en conséquence. Ajout d'un test playwright pour éviter toute régression à l'avenir 2026-05-19 19:15:00 +02:00
0e350142de mise à jour vers 0.8.7-beta
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-05-19 18:45:56 +02:00
11cb708de9 Mise à jour du switcher pour régler le soucis de switch entre stable et bêta
Some checks failed
E2E Tests / e2e (push) Failing after 29s
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-05-19 18:36:00 +02:00
53343c0a75 Mise à jour de la config du switcher pour prendre les crédit du GHCR + sh plus verbeux en cas de bugs
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-05-19 18:25:54 +02:00
43f45d68a1 Mise à jour vers 0.8.5 ; ajout de la bascule entre le canal bêta et le canal stable 2026-05-19 18:05:17 +02:00
4bbc6333fc Ajout d'un globalExceptionHandler pour intercepter toutes les erreurs possibles et avoir un peu plus de détails.
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Suppression du détail de la mise à jour de chaque composant : l'utilisateur ce fiche de savoir composant x / y à jour car on fera la mise à jour pour tout à chaque fois
(même montée en version pour chaque composant même si composant y non touché par exemple... c'est la montée en version de l'appli qui compte)
2026-05-19 14:38:38 +02:00
dc908b8d93 Mise à jour du pom coté pore pour intégrer une dépendance vers crypto.tink ; sinon la vérification de la licence patreon ne marche pas.
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-05-19 14:06:57 +02:00
ae9455c244 Ajout de tests playwright et correction de tests non passant (pour les tests ajoutés : partie game system ).
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Correction de plusieurs anomalies : problème de switch entre 2 templates (par exemple si on était sur un template 1 et qu'on voulait passer directement au 2, ce dernier ne chargeait pas) ;
correction du soucis d'apparition de la sidebar à gauche qui disparaissait sans explication ; problème de redirection : lorsqu'on terminait de créer un PJ / PNJ ; on arrivait sur l'accueil de la campagne au lieu de voir le résultat de la création.
Problème de redirection également lors du clique sur un PNJ / PJ sur le coté : on arrivait sur l'édition au lieu de la présentation. Correction de la première lettre stylisée : tout est au même style comme ça plus de probleme de lecture.

Nouveautées : stylisation des modales (notamment suppression, warning.....) avec en prime l'ajout d'un warning lors du changement de système pour avertir que les fiches persos ne sont pas conservées.
Ajout d'une option pour créer un game system directement à la création d'une campagne afin de faciliter la mise en place de cette dernière.
Ajout d'un bouton pour créer un nouveau template directement lorsqu'on créer une page : ça permet de créer un template et de revenir sur la page qu'on était en train de créer sans perdre le titre.

Passage en bêta 0.8.4
2026-05-19 13:37:22 +02:00
e346bb4b85 Changement du readme 2026-05-17 18:04:24 +02:00
a1c9e67013 Refonte de toute la partie fiche de personnage avec mise en place d'un nouveau bloc de liste d'attribut (pour tout ce qui sera statistiques, compétences etc....)
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Passage V0.8.3
2026-04-30 15:53:38 +02:00
b0edfb5aab Mise en place du picker d'image pour la partie header / illustration des fiches personnage
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
Migration pour l'ancienne partie des fiches perso vers les nouvelles pages
Vue retravaillée pour les fiches perso
2026-04-30 10:54:27 +02:00
117334315f Refonte du système JDR + système de personnage joueurs / non joueurs :
- Système de templating dans le game system : en effet, les templates sont liés au game system car les fiches personnages ne sont pas forcément les même selon les jeux (perso Dnd possède + de compétences que Nimble par exemple)
- changement des fiches personnages pour adapter le templating au niveau des campagnes et remplir des pages de perso
2026-04-30 10:42:09 +02:00
8e0c1c1017 Mise en place d'un composant permettant d'améliorer l'experience de mise à jour (via un rafraichissement de l'appli).
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Modification de la partie web pour prendre la modification en compte
2026-04-29 14:39:30 +02:00
cb9eb14054 Correction problème mise à jour : l'application ne voyait pas les mises à jour quand on lançait docker après avoir push la dernière version.
Effectivement : au demarrage, docker ce mettait automatiquement sur la dernière version alors qu'il n'avait pas necessairement récupérer, ducoup comparaison faisait true et on arrivait pas à avoir la derniere version du code.
Push de la clé jwt publique : sinon pas incluse dans le jar finale et la section patreon n'apparaissait pas.
2026-04-29 10:56:37 +02:00
fd14e1e572 Correction updateCheckServiceTest qui faisait planter le build gitea
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-28 19:12:09 +02:00
f3c445cfa1 Mise en place de la connexion au canal privé pour la bêta avec Patreon et passage en v0.8.0 2026-04-28 19:04:11 +02:00
1a5a87629d Autre patch dockerfile
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
2026-04-27 22:11:43 +02:00
e61bcd5dbb Patch dockerfile bookworm a lieu de alpine pour corriger le problème de build 2026-04-27 21:56:04 +02:00
bc8fb30398 Patch dockerfile pour ne plus que le build plante 2026-04-27 21:43:13 +02:00
ec45104a80 Correction package-lock 2026-04-27 19:17:01 +02:00
3f8cdd177b Modification lors de la création d'élément de campagne : quand on créer un nouvel élément, on arrive sur la modification et non le résumé de l'élément 2026-04-27 19:03:58 +02:00
4ed9104a53 Correction du soucis de mise à jour via l'application
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-27 16:19:56 +02:00
defbf2d1f7 Passage V0.7.0
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
2026-04-27 15:51:13 +02:00
d2cf9f8c5c Changement sur le Readme
Ajout d'une partie spécifique pour des PNJ dans la partie campagne
2026-04-27 15:48:04 +02:00
a92e31b187 Mise à jour du readme d'accueil pour l'accès à la documentation 2026-04-27 08:27:38 +02:00
00627d1543 Passage version 0.6.14 + résolution d'un soucis sur l'updater depuis la migration sur git
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-26 19:08:49 +02:00
47a8fc529d Mise en place de la pipeline pour github plutot que gitea ; mise en place des images docker sur GHCR plutôt que gitea
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
Passage version v0.6.13
2026-04-26 10:46:46 +02:00
c524516b2a Empêche la modale de ce fermer tant que le llm n'est pas télécharger
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-26 09:12:36 +02:00
6a4ae5c326 Forçage HTTP/1.1 pour la partie python et passage en v0.6.11
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
2026-04-26 01:55:02 +02:00
4e01fdfa9c Correction d'un bug lors de tentative de téléchargement de llm pour ollama
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-26 01:45:39 +02:00
b60a790c70 Passage version 0.6.9
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-26 01:30:35 +02:00
147d0701db Changement du watchtower pour une version plus récente : projet originel abandonné, repris par un fork. 2026-04-26 01:19:58 +02:00
98e54613ce Mise en place v0.6.8
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Amélioration de l'installation automatique
Ajout de la possibilité de télécharger le llm que l'on veut à l'interieur de l'application en communicant avec ollama
2026-04-26 01:11:04 +02:00
c47f987cef Mise à jour de la conf pour être sur que le cache angular est bien refresh
Mise à jour des installeurs
Mise en place de secure-host pour ne pas exposer Ollama à l'exterieur
2026-04-26 00:18:49 +02:00
7cd0a8253a Correction pour éviter que la fenêtre ce ferme sans qu'on voit le message d'erreur
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
2026-04-25 18:37:40 +02:00
7b189acee9 Ajout d'un .bat pour l'exécution du .ps1 2026-04-25 18:34:52 +02:00
08c91dd4c6 Changement sur l'installation automatique : réduction des patterns suspects dans l'installation pour les antivirus (par exemple, monter automatiquement les privilèges en admin...),
afin d'éviter que l'appli ne soit détectée comme un virus
2026-04-25 18:24:44 +02:00
dbf0b13189 Ajout d'un script pour installation automatique du produit
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
Ajout d'une partie mise à jour automatique : plus besoin de docker pull en ligne de commande ; on peut passer par l'interface
Refactoring partie Java pour respecter d'avantage le DDD : plus de jackson dans la partie domain

Passage version 0.6.6
2026-04-25 13:24:32 +02:00
aba02ca9cf Evolutions :
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
- Ajout d'icônes dans la scène, chapitre et arc
- Possibilité de bouger les cases dans la partie graphe et les textes associés si ces derniers ne sont pas visibles
- Changement sur le thème du graphe : mode sombre et plus blanc
- Barre d'action en haut, même pour la partie scène
- Mode sticky corrigé : plus de trou entre le haut du navigateur web et de la barre d'action

Passage version 0.6.5
2026-04-25 11:41:14 +02:00
854110683b Correction d'un test unitaire
Ajout d'un champs image dans les templates par défaut en + du champs nom, description pour avoir un exemple
Correction du visuel du champs d'ajout lors de la modification d'un template (apparition ligne pleine au lieu de texte en pointillé)
Ajout d'un intercepteur pour la partie démo de l'application afin de bien rafraichir le cache angular lorsque le temps de démo est expiré
2026-04-25 09:23:56 +02:00
3ad8145572 Fix : problème d'ascenseur en bas de la page au niveau des templates
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Sélection du template par défaut lors de la création d'une page en fonction du dossier
Passage v0.6.2
2026-04-25 01:39:05 +02:00
0d27ea71b0 Ajout de test, correctif d'un problème d'horloge pour le workflow gitea actions pour le e2e 2026-04-25 00:51:32 +02:00
f6b4e3ff82 Fix workflow gitea action pour e2e (tests automatisés via playwright) + correction d'une incohérence dans l'API coté java. Ajout d'autres tests utilisateur 2026-04-25 00:45:04 +02:00
2a438b79ce Mise en place de tests utilisateurs avec playwright pour la partie angular + corrections au niveau des labels avec for et id pour cliquer dessus 2026-04-25 00:25:53 +02:00
6ab7f64909 Fix rate limit derriere Cloudflare + CORS sur POST demo 2026-04-24 08:55:40 +02:00
0ad1d09802 Fix CORS 403 sur POST : passer APP_CORS_ALLOWED_ORIGINS au core démo 2026-04-24 08:46:26 +02:00
c6a228a48f Remplace docker SDK par appels HTTP directs (zero deps)
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-24 07:39:49 +02:00
9eabd2abf8 Docker SDK v28 pour resoudre les conflits transitifs 2026-04-24 07:33:48 +02:00
a23f82d635 Correction d'une dépendance go 2026-04-24 07:30:20 +02:00
280dced405 Mise à jour du dockerfile suite à une dépendance trop ancienne sur go 2026-04-24 07:26:42 +02:00
987187943e Orchestrateur go pour lancer la démo et mettre en place plusieurs instances docker 2026-04-23 17:49:26 +02:00
2e7d46cc63 Changement dans la config pour éviter les url en dur + mise en place d'un mode démo 2026-04-23 17:15:08 +02:00
93deb73ade Version 0.6.1
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-23 14:36:09 +02:00
4f1b8fdd55 Ajout des personnage dans la sidebar de la campagne 2026-04-23 14:34:07 +02:00
035a2553d1 Mutualisation de la version pour ne pas l'oublier dans le footer du front ;
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
Passage en version v 0.6.0
2026-04-23 14:12:24 +02:00
5bbae9e130 Correction bug suppression complète coté lore (et suppression dans tout ce qui est campagne de la partie lore liée).
Améliorations ux :
- Bandeau en haut qui reste accessible lors de la création d'un élément (chapitre, page, scène etc...)
- Mise en place d'un surlignage pour voir su quel élément on est positionné
2026-04-23 14:06:50 +02:00
5121e2d2e3 Mise en place de la possibilité de supprimer des lores / campagnes d'un seul coup 2026-04-23 11:51:03 +02:00
5f5b2ecb87 Corrections d'ordre graphique / ergonomique :
- Lorsqu'on part de zéro : la création de dossier / page / template ce fait de manière plus fluide à la création d'un lore (par exemple création de page sans template et dossier : parcours facilité)
- Ajout d'un bouton "+" dans le header templates
- Harmonisation création / modification template

Correction de tests unitaires
2026-04-23 11:25:58 +02:00
ceabf53c0a Correction d'un test unitaire
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
2026-04-22 13:38:48 +02:00
43e375e169 Passage en v 0.5.0
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-22 13:33:47 +02:00
68fd1698b3 Mise en place d'un bouton + au hover plutot qu'un affichage constant 2026-04-22 13:31:06 +02:00
735dd2eee0 Corrections visuel ; optimisation du chargement des pages (préchargement anticité, sinon temps de latence chaque fois qu'on visite un type de page une première fois) 2026-04-22 13:17:05 +02:00
5ea3a5097f Ajout de la partie "Système de jeu" avec toute la partie stockage de règles de notre jeu.
Ajout de possibilité de stocker des fiches de personnages associés à une campagne également (personnages joueurs pour le moment)
2026-04-22 11:58:50 +02:00
63a3c21bd2 Ecriture de tests unitaires coté java pour améliorer la stabilité de l'application 2026-04-22 07:46:24 +02:00
55 changed files with 1888 additions and 67 deletions

View File

@@ -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 ---
@@ -63,11 +69,18 @@ class NotebookDeepUseCase:
async def stream(
self,
source_ids: list[str],
question: str,
messages: list[ChatMessage],
context: str = "",
history_limit: int = 8,
) -> AsyncIterator[dict]:
"""Yield des évènements : {type:'progress',current,total}, {type:'token',token},
{type:'done'}. (Les erreurs LLM des lots sont tolérées : lot ignoré.)"""
{type:'done'}. (Les erreurs LLM des lots sont tolérées : lot ignoré.)
La dernière question utilisateur sert à la LECTURE du document (map) ; la
SYNTHÈSE (reduce) reçoit les `history_limit` derniers messages → les relances
conversationnelles (« et pour les autres ? ») fonctionnent aussi en approfondi.
"""
question = next((m.content for m in reversed(messages) if m.role == "user"), "")
chunks: list[dict] = []
for sid in source_ids:
chunks.extend(vector_store.all_chunks(sid))
@@ -102,11 +115,25 @@ class NotebookDeepUseCase:
if context.strip() else ""
)
system_prompt = _REDUCE_SYSTEM.format(context_block=context_block, notes_block=notes_block)
# Historique récent pour la cohérence des relances ; on garantit que le
# 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]
async for token in llm_chat.stream_chat(
[ChatMessage(role="user", content=question)], system_prompt=system_prompt
):
yield {"type": "token", "token": token}
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]]:

View File

@@ -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.0-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é ----------
@@ -1100,7 +1174,8 @@ async def chat_notebook_deep_stream(
) -> StreamingResponse:
"""Analyse APPROFONDIE (map-reduce sur tout le document). Évènements SSE :
`progress` {current,total} pendant la lecture, puis `token` {token}, puis `done`."""
question = next((m.content for m in reversed(body.messages) if m.role == "user"), "")
messages = [ChatMessage(role=m.role, content=m.content) for m in body.messages]
question = next((m.content for m in reversed(messages) if m.role == "user"), "")
def _sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
@@ -1110,7 +1185,7 @@ async def chat_notebook_deep_stream(
yield _sse("error", {"message": "Question vide."})
return
try:
async for ev in use_case.stream(body.source_ids, question, context=body.context):
async for ev in use_case.stream(body.source_ids, messages, context=body.context):
ev_type = ev.pop("type")
yield _sse(ev_type, ev)
except (LLMProviderError, EmbeddingError) as exc:

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId>
<version>0.11.0-beta</version>
<version>0.11.3-beta</version>
<name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description>

View File

@@ -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;
}
}

View File

@@ -29,6 +29,7 @@ public class NpcService {
Map<String, List<String>> imageValues,
Map<String, Map<String, String>> keyValueValues,
String campaignId,
String folder,
Integer order
) {}
@@ -44,6 +45,7 @@ public class NpcService {
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
.campaignId(data.campaignId())
.folder(normalizeFolder(data.folder()))
.order(order)
.build();
return npcRepository.save(npc);
@@ -66,6 +68,7 @@ public class NpcService {
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
existing.setFolder(normalizeFolder(data.folder()));
if (data.order() != null) {
existing.setOrder(data.order());
}
@@ -76,6 +79,13 @@ public class NpcService {
npcRepository.deleteById(id);
}
/** Trim le dossier ; chaîne vide → null (= non classé). */
private static String normalizeFolder(String folder) {
if (folder == null) return null;
String trimmed = folder.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private int nextOrderFor(String campaignId) {
return npcRepository.findByCampaignId(campaignId).stream()
.mapToInt(Npc::getOrder)

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -46,6 +46,9 @@ public class Npc {
/** Référence vers la Campaign parente (cross-aggregate via ID). */
private String campaignId;
/** Dossier de classement (texte libre, ex. « Bard's Gate »). Nullable = non classé. */
private String folder;
/** Ordre d'affichage dans la liste des PNJ de la campagne. */
private int order;

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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();
}
}

View File

@@ -54,6 +54,9 @@ public class NpcJpaEntity {
@Column(name = "campaign_id", nullable = false)
private Long campaignId;
@Column(name = "folder")
private String folder;
@Column(name = "\"order\"", nullable = false)
private int order;

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -59,6 +59,7 @@ public class PostgresNpcRepository implements NpcRepository {
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
.campaignId(e.getCampaignId().toString())
.folder(e.getFolder())
.order(e.getOrder())
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
@@ -76,6 +77,7 @@ public class PostgresNpcRepository implements NpcRepository {
.imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>())
.keyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>())
.campaignId(Long.parseLong(n.getCampaignId()))
.folder(n.getFolder())
.order(n.getOrder())
.createdAt(n.getCreatedAt())
.updatedAt(n.getUpdatedAt())

View File

@@ -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) {}
}

View File

@@ -28,7 +28,10 @@ import java.util.Map;
@RequestMapping("/api/notebooks")
public class NotebookController {
private static final long SSE_TIMEOUT_MS = 10 * 60 * 1000L;
// L'analyse approfondie (map-reduce sur tout le doc) peut être longue sur un gros
// livre / un modèle lent → 30 min. Pour aller plus vite : modèle gros-contexte
// (moins de lots). Au-delà, l'expiration est gérée proprement (pas de crash).
private static final long SSE_TIMEOUT_MS = 30 * 60 * 1000L;
private final NotebookService service;
private final NotebookChatStreamer chatStreamer;
@@ -143,13 +146,18 @@ public class NotebookController {
return emitter;
}
// --- Helpers SSE (mêmes conventions que AiChatController) ---
// --- Helpers SSE ---
// IMPORTANT : on attrape AUSSI IllegalStateException. Si le flux a déjà été fermé
// (timeout async, client déconnecté), `emitter.send/complete` la lève — et comme
// ces helpers tournent dans un thread d'exécuteur, une exception non gérée y
// remontait jusqu'au pool ("Exception in thread task-1: ResponseBodyEmitter has
// already completed"). On l'ignore silencieusement : il n'y a plus rien à envoyer.
private void sendToken(SseEmitter emitter, String token) {
try {
emitter.send(SseEmitter.event().name("token").data("{\"token\":" + jsonEscape(token) + "}"));
} catch (IOException e) {
emitter.completeWithError(e);
} catch (IOException | IllegalStateException e) {
// flux fermé/expiré : on cesse d'écrire
}
}
@@ -157,8 +165,8 @@ public class NotebookController {
try {
emitter.send(SseEmitter.event().name("progress")
.data("{\"current\":" + p.current() + ",\"total\":" + p.total() + "}"));
} catch (IOException e) {
emitter.completeWithError(e);
} catch (IOException | IllegalStateException e) {
// flux fermé/expiré : on cesse d'écrire
}
}
@@ -166,8 +174,8 @@ public class NotebookController {
try {
emitter.send(SseEmitter.event().name("done").data("{}"));
emitter.complete();
} catch (IOException e) {
emitter.completeWithError(e);
} catch (IOException | IllegalStateException e) {
// flux déjà fermé/expiré : rien à compléter
}
}
@@ -176,8 +184,8 @@ public class NotebookController {
String message = error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName();
emitter.send(SseEmitter.event().name("error").data("{\"message\":" + jsonEscape(message) + "}"));
emitter.complete();
} catch (IOException ioe) {
emitter.completeWithError(ioe);
} catch (IOException | IllegalStateException e) {
// flux déjà fermé/expiré : rien à envoyer
}
}

View File

@@ -64,6 +64,7 @@ public class NpcController {
dto.getImageValues(),
dto.getKeyValueValues(),
dto.getCampaignId(),
dto.getFolder(),
order
);
}

View File

@@ -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;
}

View File

@@ -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<>();
}

View File

@@ -20,5 +20,6 @@ public class NpcDTO {
private Map<String, List<String>> imageValues = new HashMap<>();
private Map<String, Map<String, String>> keyValueValues = new HashMap<>();
private String campaignId;
private String folder;
private int order;
}

View File

@@ -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();
}
}

View File

@@ -20,6 +20,7 @@ public class NpcMapper {
dto.setImageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>());
dto.setKeyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>());
dto.setCampaignId(n.getCampaignId());
dto.setFolder(n.getFolder());
dto.setOrder(n.getOrder());
return dto;
}
@@ -35,6 +36,7 @@ public class NpcMapper {
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
.keyValueValues(dto.getKeyValueValues() != null ? new HashMap<>(dto.getKeyValueValues()) : new HashMap<>())
.campaignId(dto.getCampaignId())
.folder(dto.getFolder())
.order(dto.getOrder())
.build();
}

View File

@@ -51,7 +51,7 @@ public class NpcServiceTest {
Npc result = npcService.createNpc(
new NpcService.NpcData("Borin le forgeron", null, null,
Map.of("Notes", "Borin"), null, null, "camp-1", 5));
Map.of("Notes", "Borin"), null, null, "camp-1", null,5));
assertNotNull(result);
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
@@ -67,7 +67,7 @@ public class NpcServiceTest {
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b));
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
npcService.createNpc(new NpcService.NpcData("Nouveau", null, null, null, null, null, "camp-1", null));
npcService.createNpc(new NpcService.NpcData("Nouveau", null, null, null, null, null, "camp-1", null,null));
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
verify(npcRepository).save(captor.capture());
@@ -79,7 +79,7 @@ public class NpcServiceTest {
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
npcService.createNpc(new NpcService.NpcData("Premier", null, null, null, null, null, "camp-1", null));
npcService.createNpc(new NpcService.NpcData("Premier", null, null, null, null, null, "camp-1", null,null));
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
verify(npcRepository).save(captor.capture());
@@ -124,7 +124,7 @@ public class NpcServiceTest {
Npc result = npcService.updateNpc("npc-1",
new NpcService.NpcData("Borin renommé", null, null,
Map.of("Notes", "v2"), null, null, "camp-1", 7));
Map.of("Notes", "v2"), null, null, "camp-1", null,7));
assertEquals("Borin renommé", result.getName());
assertEquals("v2", result.getValues().get("Notes"));
@@ -138,7 +138,7 @@ public class NpcServiceTest {
Npc result = npcService.updateNpc("npc-1",
new NpcService.NpcData("Borin", null, null,
Map.of("Notes", "txt"), null, null, "camp-1", null));
Map.of("Notes", "txt"), null, null, "camp-1", null,null));
// testNpc avait order=1 → préservé
assertEquals(1, result.getOrder());
@@ -150,7 +150,7 @@ public class NpcServiceTest {
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> npcService.updateNpc("missing",
new NpcService.NpcData("x", null, null, null, null, null, "camp-1", null)));
new NpcService.NpcData("x", null, null, null, null, null, "camp-1", null,null)));
assertTrue(ex.getMessage().contains("missing"));
verify(npcRepository, never()).save(any());
}

View File

@@ -7,8 +7,8 @@
# Surchargables via variables d'environnement (CI) :
# SPRING_DATASOURCE_URL, SPRING_DATASOURCE_USERNAME, SPRING_DATASOURCE_PASSWORD
spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/loremind_test}
spring.datasource.username=${SPRING_DATASOURCE_USERNAME:ietm64}
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:M&Ipourlavie64}
spring.datasource.username=${SPRING_DATASOURCE_USERNAME:loremind_test}
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:loremind_test}
spring.datasource.driver-class-name=org.postgresql.Driver
# Configuration JPA pour les tests : schema recree a chaque run, pas de logs SQL.

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "loremind-web",
"version": "0.11.0-beta",
"version": "0.11.3-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "loremind-web",
"version": "0.11.0-beta",
"version": "0.11.3-beta",
"dependencies": {
"@angular/animations": "^17.0.0",
"@angular/common": "^17.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "loremind-web",
"version": "0.11.0-beta",
"version": "0.11.3-beta",
"description": "LoreMind Frontend - Angular",
"scripts": {
"ng": "ng",

View File

@@ -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) },

View File

@@ -96,18 +96,45 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
// à une Partie (Playthrough). On ne les affiche donc plus dans la sidebar de
// campagne — seuls les PNJ (donnée de scénario) restent sous "Personnages".
const sortedNpcs = [...data.npcs].sort(byName);
const npcItems: TreeItem[] = sortedNpcs.map(n => ({
const npcItem = (n: Npc): TreeItem => ({
id: `npc-${n.id}`,
label: n.name,
route: `/campaigns/${campaignId}/npcs/${n.id}`
}));
});
// Regroupement par DOSSIER : un sous-nœud (dépliable) par dossier, puis les PNJ
// non classés directement sous « PNJ ».
const npcsByFolder = new Map<string, Npc[]>();
const ungroupedNpcs: Npc[] = [];
for (const n of sortedNpcs) {
const f = (n.folder ?? '').trim();
if (f) {
if (!npcsByFolder.has(f)) npcsByFolder.set(f, []);
npcsByFolder.get(f)!.push(n);
} else {
ungroupedNpcs.push(n);
}
}
const npcFolderNodes: TreeItem[] = [...npcsByFolder.keys()]
.sort((a, b) => a.localeCompare(b, 'fr', { sensitivity: 'base' }))
.map(folder => {
const items = npcsByFolder.get(folder)!.map(npcItem);
return {
id: `npc-folder-${folder}`,
label: folder,
iconKey: 'folder',
children: items,
meta: String(items.length)
};
});
const npcChildren: TreeItem[] = [...npcFolderNodes, ...ungroupedNpcs.map(npcItem)];
const npcsNode: TreeItem = {
id: 'npcs-root',
label: 'PNJ',
iconKey: 'c-drama',
children: npcItems,
meta: npcItems.length ? String(npcItems.length) : undefined,
children: npcChildren,
meta: sortedNpcs.length ? String(sortedNpcs.length) : undefined,
// Porte le header de section "Personnages" (les PJ ayant migré vers la Partie).
// Le filet au-dessus est masqué par CSS si c'est le tout premier item de la sidebar.
sectionHeaderBefore: 'Personnages',
@@ -198,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',
@@ -206,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];
}
/**

View File

@@ -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>

View File

@@ -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; }

View File

@@ -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']);
}
}

View File

@@ -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>

View File

@@ -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); } }
}

View File

@@ -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]);
}
}

View File

@@ -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>

View File

@@ -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; }
}

View File

@@ -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']);
}
}

View File

@@ -36,6 +36,21 @@
/>
</div>
<div class="field">
<label for="npc-folder">Dossier</label>
<input
id="npc-folder"
type="text"
[(ngModel)]="folder"
name="folder"
list="npc-folders"
placeholder="Ex: Bard's Gate, Faction des Pipers… (laisser vide = non classé)"
/>
<datalist id="npc-folders">
<option *ngFor="let f of existingFolders" [value]="f"></option>
</datalist>
</div>
<div class="field-row image-row">
<div class="field portrait-field">
<label>Portrait</label>

View File

@@ -46,6 +46,9 @@ export class NpcEditComponent implements OnInit {
npcId: string | null = null;
name = '';
folder = '';
/** Dossiers déjà utilisés dans la campagne (datalist d'auto-complétion). */
existingFolders: string[] = [];
portraitImageId: string | null = null;
headerImageId: string | null = null;
values: Record<string, string> = {};
@@ -72,12 +75,14 @@ export class NpcEditComponent implements OnInit {
if (this.campaignId) {
this.loadTemplateForCampaign(this.campaignId);
this.campaignSidebar.show(this.campaignId);
this.loadExistingFolders(this.campaignId);
}
if (this.npcId) {
this.service.getById(this.npcId).subscribe({
next: (n) => {
this.name = n.name;
this.folder = n.folder ?? '';
this.portraitImageId = n.portraitImageId ?? null;
this.headerImageId = n.headerImageId ?? null;
this.values = n.values ?? {};
@@ -90,6 +95,17 @@ export class NpcEditComponent implements OnInit {
}
}
private loadExistingFolders(campaignId: string): void {
this.service.getByCampaign(campaignId).subscribe({
next: (list) => {
this.existingFolders = [...new Set(
list.map(n => (n.folder ?? '').trim()).filter(f => f.length > 0)
)].sort((a, b) => a.localeCompare(b, 'fr'));
},
error: () => { this.existingFolders = []; }
});
}
private loadTemplateForCampaign(campaignId: string): void {
this.campaignService.getCampaignById(campaignId).subscribe({
next: (campaign) => {
@@ -111,6 +127,7 @@ export class NpcEditComponent implements OnInit {
if (!this.name.trim() || !this.campaignId) return;
const payload = {
name: this.name.trim(),
folder: this.folder.trim() || null,
portraitImageId: this.portraitImageId,
headerImageId: this.headerImageId,
values: this.values,

View File

@@ -1,6 +1,7 @@
import { Component, OnInit } from '@angular/core';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { Subscription } from 'rxjs';
import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular';
import { NpcService } from '../../../services/npc.service';
import { CampaignService } from '../../../services/campaign.service';
@@ -22,7 +23,7 @@ import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-dr
templateUrl: './npc-view.component.html',
styleUrls: ['./npc-view.component.scss']
})
export class NpcViewComponent implements OnInit {
export class NpcViewComponent implements OnInit, OnDestroy {
readonly ArrowLeft = ArrowLeft;
readonly Edit3 = Edit3;
readonly Sparkles = Sparkles;
@@ -36,6 +37,8 @@ export class NpcViewComponent implements OnInit {
chatOpen = false;
toggleChat(): void { this.chatOpen = !this.chatOpen; }
private paramsSub?: Subscription;
constructor(
private route: ActivatedRoute,
private router: Router,
@@ -46,25 +49,42 @@ export class NpcViewComponent implements OnInit {
) {}
ngOnInit(): void {
const params = this.route.snapshot.paramMap;
this.campaignId = params.get('campaignId');
this.npcId = params.get('npcId');
if (this.npcId) {
this.service.getById(this.npcId).subscribe({
next: n => { this.npc = n; },
error: () => this.back()
});
}
if (this.campaignId) {
this.campaignSidebar.show(this.campaignId);
this.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
if (camp.gameSystemId) {
this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => {
this.templateFields = gs.npcTemplate ?? [];
});
}
});
}
// S'abonner aux paramMap (pas le snapshot) : quand on passe d'un PNJ à un autre,
// Angular RÉUTILISE le composant (même route) → ngOnInit ne re-tourne pas. Sans ce
// subscribe, la fiche du centre resterait figée sur l'ancien PNJ.
this.paramsSub = this.route.paramMap.subscribe(params => {
const newCampaignId = params.get('campaignId');
this.npcId = params.get('npcId');
// Recharge la fiche à CHAQUE changement de PNJ.
this.chatOpen = false;
if (this.npcId) {
this.service.getById(this.npcId).subscribe({
next: n => { this.npc = n; },
error: () => this.back()
});
}
// Sidebar + template du système : seulement quand la campagne change (inutile
// de les recharger à chaque switch de PNJ d'une même campagne).
if (newCampaignId && newCampaignId !== this.campaignId) {
this.campaignId = newCampaignId;
this.campaignSidebar.show(this.campaignId);
this.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
if (camp.gameSystemId) {
this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => {
this.templateFields = gs.npcTemplate ?? [];
});
}
});
} else if (newCampaignId) {
this.campaignId = newCampaignId;
}
});
}
ngOnDestroy(): void {
this.paramsSub?.unsubscribe();
}
edit(): void {

View File

@@ -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. */

View 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[];
}

View 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 });
}
}

View File

@@ -11,6 +11,8 @@ export interface Npc {
imageValues?: Record<string, string[]>;
keyValueValues?: Record<string, Record<string, string>>;
campaignId: string;
/** Dossier de classement (ex. « Bard's Gate »). Vide/absent = non classé. */
folder?: string | null;
order?: number;
}
@@ -22,4 +24,5 @@ export interface NpcCreate {
imageValues?: Record<string, string[]>;
keyValueValues?: Record<string, Record<string, string>>;
campaignId: string;
folder?: string | null;
}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>

View File

@@ -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; }
}
}

View File

@@ -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}`);
}
}

View File

@@ -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>

View File

@@ -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);
}
}