Compare commits

..

80 Commits

Author SHA1 Message Date
5aea0a5af2 passage version v0.11.3-beta
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m23s
Build & Push Images / build (core) (push) Successful in 1m44s
Build & Push Images / build-switcher (push) Successful in 18s
Build & Push Images / build (web) (push) Successful in 1m41s
2026-06-08 17:01:05 +02:00
da2a66d06a Amélioration de l'IA pour la partie atelier PDF
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
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
b968f12a0c Modification du timeout pour permettre d'analyser de plus gros livres
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m28s
Build & Push Images / build (core) (push) Successful in 1m48s
Build & Push Images / build-switcher (push) Successful in 16s
Build & Push Images / build (web) (push) Successful in 1m41s
2026-06-08 14:49:00 +02:00
ba5f9c7a2e Mise à jour d'un test unitaire qui ne passait plus avec la mise à jour précédente
All checks were successful
Build & Push Images / build (core) (push) Successful in 2m9s
Build & Push Images / build (brain) (push) Successful in 2m25s
Build & Push Images / build-switcher (push) Successful in 17s
Build & Push Images / build (web) (push) Successful in 1m44s
2026-06-08 11:11:50 +02:00
55a202caa8 passage version 0.11.1-beta
Some checks failed
Build & Push Images / build (core) (push) Failing after 1m32s
Build & Push Images / build (brain) (push) Successful in 2m5s
Build & Push Images / build-switcher (push) Successful in 18s
Build & Push Images / build (web) (push) Successful in 2m9s
2026-06-08 11:05:48 +02:00
0bbcf11b5f 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
75357983a1 Plusieurs gros ajouts :
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m29s
Build & Push Images / build (core) (push) Successful in 1m51s
Build & Push Images / build-switcher (push) Successful in 16s
Build & Push Images / build (web) (push) Successful in 1m43s
- 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
76977eda0e passage 0.10.4
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m33s
Build & Push Images / build (core) (push) Successful in 1m39s
Build & Push Images / build-switcher (push) Successful in 20s
Build & Push Images / build (web) (push) Successful in 1m39s
2026-06-05 16:54:41 +02:00
33d8040098 Meilleure gestion des erreurs, moins permissif au niveau de la créativité du modèle. 2026-06-05 16:54:28 +02:00
0bd4a2d10f Ajout d'open router en fournisseur IA ; ajout de la possibilité de mettre des conditions de déverouillage pour les chapitres optionnels
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m22s
Build & Push Images / build (core) (push) Successful in 1m42s
Build & Push Images / build-switcher (push) Successful in 22s
Build & Push Images / build (web) (push) Successful in 1m43s
2026-06-05 00:23:19 +02:00
bf91b75b5f changement de version
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m29s
Build & Push Images / build (core) (push) Successful in 1m49s
Build & Push Images / build-switcher (push) Successful in 15s
Build & Push Images / build (web) (push) Successful in 1m45s
2026-06-04 14:14:19 +02:00
2fffad1b6b Correction de la configuration nginx pour ne pas bloquer un gros PDF 2026-06-04 14:13:41 +02:00
439f43875b Ajout de 2 fonctionnalitées principales : import PDF que ce soit pour les règles ou les campagnes directement.
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m28s
Build & Push Images / build (core) (push) Successful in 1m40s
Build & Push Images / build-switcher (push) Successful in 19s
Build & Push Images / build (web) (push) Successful in 1m46s
Fonctionnalité de comparaison PDF / campagne pour faire un mix et demander des conseils à l'IA
2026-06-04 13:55:27 +02:00
79a68bc27b Mise à jour des tests unitaires et passage en v0.9.2-beta en conséquence.
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m36s
Build & Push Images / build (core) (push) Successful in 1m58s
Build & Push Images / build-switcher (push) Successful in 15s
Build & Push Images / build (web) (push) Successful in 3m1s
2026-06-03 15:56:26 +02:00
c3d3394ec7 Plusieurs ajouts :
Some checks failed
Build & Push Images / build (brain) (push) Successful in 1m47s
Build & Push Images / build (core) (push) Failing after 1m56s
Build & Push Images / build-switcher (push) Successful in 20s
Build & Push Images / build (web) (push) Successful in 2m11s
- 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
694f687fec 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 :
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m20s
Build & Push Images / build (core) (push) Successful in 1m50s
Build & Push Images / build-switcher (push) Successful in 18s
Build & Push Images / build (web) (push) Successful in 1m47s
- 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
87865338a0 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
586ddceff6 mise à jour vers 0.8.7-beta
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m2s
Build & Push Images / build (core) (push) Successful in 1m38s
Build & Push Images / build-switcher (push) Successful in 20s
Build & Push Images / build (web) (push) Successful in 1m33s
2026-05-19 18:45:56 +02:00
4b9b7f0995 Mise à jour du switcher pour régler le soucis de switch entre stable et bêta
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m5s
Build & Push Images / build (core) (push) Successful in 1m42s
Build & Push Images / build (web) (push) Successful in 1m38s
Build & Push Images / build-switcher (push) Successful in 1m48s
2026-05-19 18:36:00 +02:00
3d73b1e6a7 Mise à jour de la config du switcher pour prendre les crédit du GHCR + sh plus verbeux en cas de bugs
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 59s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build-switcher (push) Successful in 43s
Build & Push Images / build (web) (push) Successful in 1m36s
2026-05-19 18:25:54 +02:00
759e47fc1f Mise à jour vers 0.8.5 ; ajout de la bascule entre le canal bêta et le canal stable
Some checks failed
E2E Tests / e2e (push) Failing after 21s
Build & Push Images / build (brain) (push) Successful in 1m0s
Build & Push Images / build (core) (push) Successful in 1m34s
Build & Push Images / build-switcher (push) Successful in 39s
Build & Push Images / build (web) (push) Successful in 1m40s
2026-05-19 18:05:17 +02:00
f71bf3fcad Ajout d'un globalExceptionHandler pour intercepter toutes les erreurs possibles et avoir un peu plus de détails.
All checks were successful
Build & Push Images / build (brain) (push) Successful in 2m27s
Build & Push Images / build (core) (push) Successful in 3m15s
Build & Push Images / build (web) (push) Successful in 2m49s
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
0cd99dfb32 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 are pending
E2E Tests / e2e (push) Waiting to run
Build & Push Images / build (brain) (push) Successful in 1m51s
Build & Push Images / build (core) (push) Successful in 1m52s
Build & Push Images / build (web) (push) Successful in 1m39s
2026-05-19 14:06:57 +02:00
f24ef0891e Ajout de tests playwright et correction de tests non passant (pour les tests ajoutés : partie game system ).
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m36s
Build & Push Images / build (core) (push) Successful in 2m53s
Build & Push Images / build (web) (push) Successful in 2m36s
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
7c74c12f3e Changement du readme
Some checks failed
E2E Tests / e2e (push) Failing after 58s
2026-05-17 18:04:24 +02:00
86836ad81c 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
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m5s
Build & Push Images / build (core) (push) Successful in 1m38s
Build & Push Images / build (web) (push) Successful in 1m36s
Passage V0.8.3
2026-04-30 15:53:38 +02:00
7c4a42327d Mise en place du picker d'image pour la partie header / illustration des fiches personnage
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m1s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build (web) (push) Successful in 1m42s
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
52e389db24 Refonte du système JDR + système de personnage joueurs / non joueurs :
Some checks failed
E2E Tests / e2e (push) Failing after 21s
- 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
efaf5a3794 Mise en place d'un composant permettant d'améliorer l'experience de mise à jour (via un rafraichissement de l'appli).
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m0s
Build & Push Images / build (core) (push) Successful in 1m47s
Build & Push Images / build (web) (push) Successful in 1m38s
Modification de la partie web pour prendre la modification en compte
2026-04-29 14:39:30 +02:00
4fe93b5ff3 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.
Some checks failed
E2E Tests / e2e (push) Failing after 21s
Build & Push Images / build (brain) (push) Successful in 1m4s
Build & Push Images / build (core) (push) Successful in 1m31s
Build & Push Images / build (web) (push) Successful in 1m38s
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
0f2d1b1efe Correction updateCheckServiceTest qui faisait planter le build gitea
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (core) (push) Successful in 1m27s
Build & Push Images / build (web) (push) Successful in 1m34s
Build & Push Images / build (brain) (push) Successful in 53s
2026-04-28 19:12:09 +02:00
5ff05242a8 Mise en place de la connexion au canal privé pour la bêta avec Patreon et passage en v0.8.0
Some checks failed
E2E Tests / e2e (push) Failing after 16s
Build & Push Images / build (brain) (push) Failing after 48s
Build & Push Images / build (core) (push) Failing after 1m18s
Build & Push Images / build (web) (push) Successful in 1m35s
2026-04-28 19:04:11 +02:00
b06c77a1eb Autre patch dockerfile
Some checks failed
E2E Tests / e2e (push) Failing after 20s
Build & Push Images / build (brain) (push) Successful in 58s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build (web) (push) Successful in 1m30s
2026-04-27 22:11:43 +02:00
03bc669efe Patch dockerfile bookworm a lieu de alpine pour corriger le problème de build
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 58s
Build & Push Images / build (core) (push) Successful in 1m39s
Build & Push Images / build (web) (push) Failing after 1m48s
2026-04-27 21:56:04 +02:00
c3873ddd84 Patch dockerfile pour ne plus que le build plante
Some checks failed
E2E Tests / e2e (push) Failing after 16s
Build & Push Images / build (brain) (push) Successful in 1m8s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build (web) (push) Failing after 1m42s
2026-04-27 21:43:13 +02:00
d7ceeac1b0 Correction package-lock
Some checks failed
E2E Tests / e2e (push) Failing after 20s
Build & Push Images / build (brain) (push) Successful in 57s
Build & Push Images / build (core) (push) Successful in 1m34s
Build & Push Images / build (web) (push) Failing after 1m41s
2026-04-27 19:17:01 +02:00
cdbd3cd9b4 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
Some checks failed
E2E Tests / e2e (push) Failing after 23s
Build & Push Images / build (brain) (push) Successful in 58s
Build & Push Images / build (core) (push) Successful in 1m33s
Build & Push Images / build (web) (push) Failing after 1m39s
2026-04-27 19:03:58 +02:00
a708c74425 Correction du soucis de mise à jour via l'application
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 59s
Build & Push Images / build (core) (push) Successful in 1m39s
Build & Push Images / build (web) (push) Successful in 1m36s
2026-04-27 16:19:56 +02:00
9ad7651c44 Passage V0.7.0
Some checks failed
E2E Tests / e2e (push) Failing after 17s
Build & Push Images / build (brain) (push) Successful in 1m13s
Build & Push Images / build (core) (push) Successful in 1m37s
Build & Push Images / build (web) (push) Successful in 1m43s
2026-04-27 15:51:13 +02:00
389392fd1d 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
aaebeaa547 Mise à jour du readme d'accueil pour l'accès à la documentation
Some checks failed
E2E Tests / e2e (push) Failing after 17s
2026-04-27 08:27:38 +02:00
03ee3855f5 Passage version 0.6.14 + résolution d'un soucis sur l'updater depuis la migration sur git
Some checks failed
E2E Tests / e2e (push) Failing after 24s
Build & Push Images / build (brain) (push) Successful in 59s
Build & Push Images / build (core) (push) Successful in 1m36s
Build & Push Images / build (web) (push) Successful in 1m47s
2026-04-26 19:08:49 +02:00
94a39cf3b4 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
E2E Tests / e2e (push) Failing after 22s
Build & Push Images / build (brain) (push) Successful in 58s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build (web) (push) Successful in 1m40s
Passage version v0.6.13
2026-04-26 10:46:46 +02:00
efe6f6c2b0 Empêche la modale de ce fermer tant que le llm n'est pas télécharger
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m20s
Build & Push Images / build (web) (push) Successful in 1m30s
2026-04-26 09:12:36 +02:00
73a9d15786 Forçage HTTP/1.1 pour la partie python et passage en v0.6.11
Some checks failed
E2E Tests / e2e (push) Failing after 20s
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m17s
Build & Push Images / build (web) (push) Successful in 1m27s
2026-04-26 01:55:02 +02:00
dfe05cf2d2 Correction d'un bug lors de tentative de téléchargement de llm pour ollama
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 50s
Build & Push Images / build (core) (push) Successful in 1m22s
Build & Push Images / build (web) (push) Successful in 1m28s
2026-04-26 01:45:39 +02:00
fcba907438 Passage version 0.6.9
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 47s
Build & Push Images / build (core) (push) Successful in 1m18s
Build & Push Images / build (web) (push) Successful in 1m24s
2026-04-26 01:30:35 +02:00
5739602702 Changement du watchtower pour une version plus récente : projet originel abandonné, repris par un fork.
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Failing after 20s
Build & Push Images / build (web) (push) Failing after 21s
Build & Push Images / build (core) (push) Failing after 22s
2026-04-26 01:19:58 +02:00
addf78f01d Mise en place v0.6.8
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 52s
Build & Push Images / build (core) (push) Successful in 1m20s
Build & Push Images / build (web) (push) Successful in 1m30s
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
5e04e84ee4 Mise à jour de la conf pour être sur que le cache angular est bien refresh
Some checks failed
E2E Tests / e2e (push) Failing after 20s
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
8d5c2e2b7f Correction pour éviter que la fenêtre ce ferme sans qu'on voit le message d'erreur
Some checks failed
E2E Tests / e2e (push) Failing after 16s
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m18s
Build & Push Images / build (web) (push) Successful in 1m24s
2026-04-25 18:37:40 +02:00
788d2c12f2 Ajout d'un .bat pour l'exécution du .ps1
Some checks failed
E2E Tests / e2e (push) Failing after 16s
2026-04-25 18:34:52 +02:00
b25a9746cf 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...),
Some checks failed
E2E Tests / e2e (push) Failing after 17s
afin d'éviter que l'appli ne soit détectée comme un virus
2026-04-25 18:24:44 +02:00
41fda9aeee Ajout d'un script pour installation automatique du produit
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 45s
Build & Push Images / build (core) (push) Successful in 1m16s
Build & Push Images / build (web) (push) Successful in 1m26s
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
550078268c Evolutions :
Some checks failed
Build & Push Images / build (brain) (push) Successful in 55s
Build & Push Images / build (core) (push) Successful in 1m35s
E2E Tests / e2e (push) Failing after 4m10s
Build & Push Images / build (web) (push) Successful in 2m0s
- 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
0582690dca Correction d'un test unitaire
Some checks failed
E2E Tests / e2e (push) Failing after 3m43s
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
88278bd1dd Fix : problème d'ascenseur en bas de la page au niveau des templates
Some checks failed
Build & Push Images / build (brain) (push) Successful in 1m1s
Build & Push Images / build (core) (push) Successful in 1m32s
E2E Tests / e2e (push) Failing after 4m13s
Build & Push Images / build (web) (push) Successful in 1m53s
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
d24d6459a0 Ajout de test, correctif d'un problème d'horloge pour le workflow gitea actions pour le e2e
Some checks failed
E2E Tests / e2e (push) Failing after 3m33s
2026-04-25 00:51:32 +02:00
4b866e5212 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
Some checks failed
E2E Tests / e2e (push) Failing after 2m31s
2026-04-25 00:45:04 +02:00
6c6bd20f0d Mise en place de tests utilisateurs avec playwright pour la partie angular + corrections au niveau des labels avec for et id pour cliquer dessus
Some checks failed
E2E Tests / e2e (push) Failing after 5m30s
2026-04-25 00:25:53 +02:00
2764228abf Fix rate limit derriere Cloudflare + CORS sur POST demo 2026-04-24 08:55:40 +02:00
f95d69c915 Fix CORS 403 sur POST : passer APP_CORS_ALLOWED_ORIGINS au core démo 2026-04-24 08:46:26 +02:00
70351e9d9a Remplace docker SDK par appels HTTP directs (zero deps)
All checks were successful
Build & Push Images / build (brain) (push) Successful in 52s
Build & Push Images / build (core) (push) Successful in 1m28s
Build & Push Images / build (web) (push) Successful in 1m34s
2026-04-24 07:39:49 +02:00
ff4905126d Docker SDK v28 pour resoudre les conflits transitifs 2026-04-24 07:33:48 +02:00
0e5b5a7de4 Correction d'une dépendance go 2026-04-24 07:30:20 +02:00
c8c032336b Mise à jour du dockerfile suite à une dépendance trop ancienne sur go 2026-04-24 07:26:42 +02:00
dda27e55fc Orchestrateur go pour lancer la démo et mettre en place plusieurs instances docker 2026-04-23 17:49:26 +02:00
83ac67471e 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
e3c8232e38 Version 0.6.1
All checks were successful
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m19s
Build & Push Images / build (web) (push) Successful in 1m31s
2026-04-23 14:36:09 +02:00
a4df9fc759 Ajout des personnage dans la sidebar de la campagne 2026-04-23 14:34:07 +02:00
f1989c1d77 Mutualisation de la version pour ne pas l'oublier dans le footer du front ;
All checks were successful
Build & Push Images / build (brain) (push) Successful in 50s
Build & Push Images / build (core) (push) Successful in 1m29s
Build & Push Images / build (web) (push) Successful in 1m24s
Passage en version v 0.6.0
2026-04-23 14:12:24 +02:00
8efdf5d0e0 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
96bc5de942 Mise en place de la possibilité de supprimer des lores / campagnes d'un seul coup 2026-04-23 11:51:03 +02:00
84ccdd53ad 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
29978058ee Correction d'un test unitaire
All checks were successful
Build & Push Images / build (brain) (push) Successful in 49s
Build & Push Images / build (core) (push) Successful in 1m24s
Build & Push Images / build (web) (push) Successful in 1m22s
2026-04-22 13:38:48 +02:00
e510f64336 Passage en v 0.5.0
Some checks failed
Build & Push Images / build (brain) (push) Successful in 47s
Build & Push Images / build (core) (push) Failing after 1m19s
Build & Push Images / build (web) (push) Successful in 1m23s
2026-04-22 13:33:47 +02:00
f189f67aaf Mise en place d'un bouton + au hover plutot qu'un affichage constant 2026-04-22 13:31:06 +02:00
8efa148739 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
8f4dd3e9d6 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
bf38b6695f Ecriture de tests unitaires coté java pour améliorer la stabilité de l'application 2026-04-22 07:46:24 +02:00
43 changed files with 1737 additions and 29 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 ---
@@ -113,8 +119,21 @@ class NotebookDeepUseCase:
# dernier message est bien la question courante.
reduce_messages = messages[-history_limit:] if messages else [ChatMessage(role="user", content=question)]
llm_chat: LLMChatProvider = self._llm # type: ignore[assignment]
produced = False
async for token in llm_chat.stream_chat(reduce_messages, system_prompt=system_prompt):
yield {"type": "token", "token": token}
if token:
produced = True
yield {"type": "token", "token": token}
if not produced:
# Jamais de bulle vide : message de repli + orientation vers le mode rapide,
# mieux adapté aux demandes créatives (et qui propose des cartes d'action).
yield {"type": "token", "token": (
"Je n'ai pas trouvé d'éléments pertinents dans le document pour cette demande "
"(elle porte sans doute sur des éléments que tu as inventés). Pour une "
"**adaptation créative** — proposer des arcs, chapitres, scènes ou PNJ — "
"utilise plutôt le bouton **« Envoyer »** (mode rapide) : il est conversationnel, "
"voit ta campagne, et te propose des cartes « Créer dans la campagne »."
)}
yield {"type": "done"}
def _group(self, chunks: list[dict]) -> list[list[dict]]:

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.1-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é ----------

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId>
<version>0.11.1-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

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

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

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

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

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

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

@@ -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:loremind_test}
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:loremind_test}
spring.datasource.username=${SPRING_DATASOURCE_USERNAME:ietm64}
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:M&Ipourlavie64}
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.1-beta",
"version": "0.11.3-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "loremind-web",
"version": "0.11.1-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.1-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

@@ -225,6 +225,14 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
route: `/campaigns/${campaignId}/notebooks`
};
// Catalogues d'objets (boutiques, butins…) → page de liste (outil).
const catalogsNode: TreeItem = {
id: 'item-catalogs-root',
label: 'Catalogues d\'objets',
iconKey: 'package',
route: `/campaigns/${campaignId}/item-catalogs`
};
// Importer un PDF de campagne → arborescence (outil, comme tables & ateliers).
const importNode: TreeItem = {
id: 'import-pdf-root',
@@ -233,7 +241,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
route: `/campaigns/${campaignId}/import`
};
return [...arcNodes, npcsNode, tablesNode, notebooksNode, importNode];
return [...arcNodes, npcsNode, tablesNode, notebooksNode, catalogsNode, importNode];
}
/**

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

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

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