Compare commits
6 Commits
v0.8.2
...
759e47fc1f
| Author | SHA1 | Date | |
|---|---|---|---|
| 759e47fc1f | |||
| f71bf3fcad | |||
| 0cd99dfb32 | |||
| f24ef0891e | |||
| 7c74c12f3e | |||
| 86836ad81c |
@@ -42,19 +42,24 @@ jobs:
|
|||||||
username: ${{ env.GHCR_NAMESPACE }}
|
username: ${{ env.GHCR_NAMESPACE }}
|
||||||
password: ${{ secrets.GHCR_TOKEN }}
|
password: ${{ secrets.GHCR_TOKEN }}
|
||||||
|
|
||||||
- name: Extract version
|
# Detection du canal :
|
||||||
|
# - tag vX.Y.Z -> stable (push :latest + :version sur les repos publics)
|
||||||
|
# - tag vX.Y.Z-beta* -> beta (push :beta + :version sur les repos GHCR prives
|
||||||
|
# loremind-beta-<component> ; backup Gitea avec :version)
|
||||||
|
- name: Extract version & channel
|
||||||
id: meta
|
id: meta
|
||||||
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
run: |
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||||
|
if [[ "${VERSION}" == *-beta* ]]; then
|
||||||
|
echo "channel=beta" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "channel=stable" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
# Push vers les deux registries en un seul build (build-push-action
|
# Build & push canal STABLE
|
||||||
# accepte une liste de tags ; aucun build supplementaire necessaire).
|
- name: Build & push ${{ matrix.component }} (stable)
|
||||||
# Naming :
|
if: steps.meta.outputs.channel == 'stable'
|
||||||
# - Gitea : conserve l'ancien pattern ietm64/<component> pour ne pas
|
|
||||||
# casser les installs existantes qui ont REGISTRY=git.igmlcreation.fr
|
|
||||||
# dans leur .env.
|
|
||||||
# - GHCR : nouveau pattern igmlcreation/loremind-<component> qui evite
|
|
||||||
# la collision avec d'autres projets de l'org.
|
|
||||||
- name: Build & push ${{ matrix.component }}
|
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: ./${{ matrix.component }}
|
context: ./${{ matrix.component }}
|
||||||
@@ -64,3 +69,73 @@ jobs:
|
|||||||
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||||
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:latest
|
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:latest
|
||||||
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||||
|
|
||||||
|
# Build & push canal BETA
|
||||||
|
# GHCR : repos prives loremind-beta-<component> (gated par PAT distribue
|
||||||
|
# via le relais Patreon aux tiers Compagnon).
|
||||||
|
# Gitea : backup prive avec :version uniquement (pas de :latest pour ne
|
||||||
|
# pas faire upgrader les installs branchees sur Gitea).
|
||||||
|
- name: Build & push ${{ matrix.component }} (beta)
|
||||||
|
if: steps.meta.outputs.channel == 'beta'
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./${{ matrix.component }}
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||||
|
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-beta-${{ matrix.component }}:beta
|
||||||
|
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-beta-${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||||
|
|
||||||
|
# Job separe pour le sidecar `switcher`.
|
||||||
|
# Pourquoi separe : le switcher est volontairement HORS de IMAGE_NAMESPACE
|
||||||
|
# (cf. docker-compose.yml). Il est toujours pulle depuis le repo public
|
||||||
|
# `loremind-switcher`, quel que soit le canal de l'instance. On le build
|
||||||
|
# donc uniquement sur les releases stables — pas la peine de re-publier
|
||||||
|
# une variante beta du switcher, c'est une infrastructure neutre.
|
||||||
|
build-switcher:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Detect channel
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||||
|
if [[ "${VERSION}" == *-beta* ]]; then
|
||||||
|
echo "channel=beta" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "channel=stable" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Login to Gitea Registry
|
||||||
|
if: steps.meta.outputs.channel == 'stable'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.GITEA_REGISTRY }}
|
||||||
|
username: ${{ env.GITEA_REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.DOCKER_PAT }}
|
||||||
|
|
||||||
|
- name: Login to GHCR
|
||||||
|
if: steps.meta.outputs.channel == 'stable'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.GHCR_REGISTRY }}
|
||||||
|
username: ${{ env.GHCR_NAMESPACE }}
|
||||||
|
password: ${{ secrets.GHCR_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build & push switcher (stable only)
|
||||||
|
if: steps.meta.outputs.channel == 'stable'
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./switcher
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/switcher:latest
|
||||||
|
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/switcher:${{ steps.meta.outputs.version }}
|
||||||
|
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-switcher:latest
|
||||||
|
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-switcher:${{ steps.meta.outputs.version }}
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -107,3 +107,4 @@ docker-compose.override.yml
|
|||||||
# Relais OAuth Patreon (repo Gitea separe, clone localement pour facilite)
|
# Relais OAuth Patreon (repo Gitea separe, clone localement pour facilite)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
relay/
|
relay/
|
||||||
|
scripts/bump-version.mjs
|
||||||
|
|||||||
67
README.md
67
README.md
@@ -1,37 +1,66 @@
|
|||||||
# LoreMind
|
# LoreMind
|
||||||
|
|
||||||
|
> Application web auto-hébergeable pour MJ qui veulent centraliser leur univers, leurs campagnes et leurs personnages — avec un assistant IA contextuel.
|
||||||
|
|
||||||
|
[](LICENSE)
|
||||||
|
[](https://loremind-docs.igmlcreation.fr/)
|
||||||
|
[](https://loremind-demo.igmlcreation.fr/)
|
||||||
|
[](https://www.patreon.com/c/IGMLCreation)
|
||||||
|
[](https://discord.gg/cPpFzCjEzQ)
|
||||||
|
|
||||||
|
## Découvrir LoreMind en vidéo
|
||||||
|
|
||||||
|
[](https://www.youtube.com/watch?v=llJkmlotbB8)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Loremind est une application web angular auto-hébergable afin de venir en aide aux Maîtres de jeu qui souhaitent centraliser leur univers et leurs campagnes.
|
## Ce que ça fait
|
||||||
Cette dernière intègre un moteur IA qui va ingérer le contenu du lore et de la campagne afin de pouvoir répondre à des questions précises sur l'univers ou la campagne, mais également proposer des idées de création dans le contexte de la campagne et du lore.
|
|
||||||
Pour le moment seul Ollama est supporté pour la partie locale, il y-a également une intégration pour 1min.ai. Plus tard, d'autres moteurs seront supportés.
|
LoreMind regroupe ce qu'un MJ utilise habituellement éparpillé entre plusieurs outils. L'application s'articule autour de trois modules principaux, augmentés par un assistant IA qui exploite tout votre contenu.
|
||||||
|
|
||||||
|
### Lore
|
||||||
|
|
||||||
|
Construire votre univers avec une arborescence de pages templatées : lieux, factions, PNJ, événements, organisations... Chaque type de page suit un template configurable, ce qui garantit la cohérence et facilite la navigation dans des univers riches.
|
||||||
|
|
||||||
|
### Game System
|
||||||
|
|
||||||
|
Stocker les règles de votre système de jeu (D&D, Nimble, créations maison...) et définir les modèles de fiches de personnages associés. Les règles indexées peuvent être injectées dans le contexte de l'IA pour des réponses fidèles à votre système.
|
||||||
|
|
||||||
|
### Campaign
|
||||||
|
|
||||||
|
Structurer vos campagnes en Arcs → Chapitres → Scènes avec séparation claire du contenu MJ et du contenu joueurs. Gérer les PJ et PNJ via des fiches dynamiques basées sur les templates du game system retenu.
|
||||||
|
|
||||||
|
### Assistant IA
|
||||||
|
|
||||||
|
Un assistant contextuel qui pioche dans votre Lore, vos règles et vos campagnes pour répondre à vos questions, suggérer du contenu cohérent, ou rebondir sur une situation improvisée en table.
|
||||||
|
|
||||||
|
L'IA s'exécute **en local via [Ollama](https://ollama.com/)** ou via **[1min.ai](https://1min.ai/)**. D'autres moteurs seront supportés à l'avenir.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
La documentation complète est accessible sur le site [loremind-docs](https://loremind-docs.igmlcreation.fr/)
|
Toute la documentation (installation, configuration, prise en main) est sur **[loremind-docs.igmlcreation.fr](https://loremind-docs.igmlcreation.fr/)**.
|
||||||
|
|
||||||
Pour l'installation, consultez le guide dans cette dernière .
|
## Démo en ligne
|
||||||
|
|
||||||
## Fonctionnalités
|
Une instance de démonstration est disponible sur **[loremind-demo.igmlcreation.fr](https://loremind-demo.igmlcreation.fr/)**.
|
||||||
|
|
||||||
- Gestion centralisée du Lore : Lieux, Factions, PNJ, et tous les éléments de votre univers
|
Quelques limites à connaître :
|
||||||
- Suivi de campagnes : Sessions, actions des joueurs, chronologie
|
- 10 utilisateurs maximum simultanés (instances isolées)
|
||||||
- Moteur IA intégré : Génération automatique de contenu (PNJ, Villes, Quêtes) à partir de templates
|
- Session limitée à 20 minutes avant réinitialisation
|
||||||
|
- Partie IA non incluse dans la démo (nécessite Ollama ou 1min.ai côté serveur)
|
||||||
|
|
||||||
## Démo
|
## Soutenir le projet
|
||||||
|
|
||||||
Une démo est disponible sur le site [loremind-demo](https://loremind-demo.igmlcreation.fr/)
|
LoreMind est **et restera gratuit en auto-hébergement**. Le développement avance plus vite avec votre soutien :
|
||||||
|
|
||||||
!! Attention, la démo est uniquement accessible à 10 personnes à la fois (instances personnalisées). Cette limite est mise en place pour éviter l'overhead sur les ressources serveur.
|
- **[Patreon](https://www.patreon.com/c/IGMLCreation)** — accès anticipé aux features, vote sur la roadmap, devlogs exclusifs
|
||||||
|
- **[Discord](https://discord.gg/cPpFzCjEzQ)** — annonces, support, retours utilisateurs
|
||||||
|
|
||||||
Cette dernière est utilisable 20 minutes maximum par session avant d'être réinitialiser.
|
## Licence
|
||||||
Vous comprendrez également qu'elle ne contient pas de démo pour la partie IA, pour laquelle il faut configurer un serveur Ollama (et qui ferait donc exploser le serveur) ou utiliser 1min.ai.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
LoreMind est distribué sous licence **[GNU AGPL v3](LICENSE)**.
|
LoreMind est distribué sous licence **[GNU AGPL v3](LICENSE)**.
|
||||||
|
|
||||||
En pratique :
|
En pratique :
|
||||||
- Tu peux l'utiliser gratuitement, l'héberger où tu veux, le modifier, le redistribuer.
|
- Vous pouvez l'utiliser gratuitement, l'héberger, la modifier, la redistribuer.
|
||||||
- Si tu modifies le code et que tu exposes l'application modifiée sur un réseau (même en SaaS privé), tu dois rendre tes modifications publiques sous la même licence.
|
- Si vous modifiez le code et que vous exposez l'application modifiée sur un réseau (même en SaaS privé), vous devez rendre vos modifications publiques sous la même licence.
|
||||||
- Les univers (Lore) et campagnes que tu crées avec LoreMind **t'appartiennent entièrement** — la licence ne couvre que le code de l'application.
|
- Les univers (Lore) et campagnes que vous créez avec LoreMind **vous appartiennent entièrement** — la licence ne couvre que le code de l'application.
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="LoreMind Brain",
|
title="LoreMind Brain",
|
||||||
description="Backend IA pour la génération de contenu narratif.",
|
description="Backend IA pour la génération de contenu narratif.",
|
||||||
version="0.6.6",
|
version="0.8.5",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
13
core/pom.xml
13
core/pom.xml
@@ -8,13 +8,13 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-parent</artifactId>
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
<version>3.2.0</version>
|
<version>3.2.12</version>
|
||||||
<relativePath/>
|
<relativePath/>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<groupId>com.loremind</groupId>
|
<groupId>com.loremind</groupId>
|
||||||
<artifactId>loremind-core</artifactId>
|
<artifactId>loremind-core</artifactId>
|
||||||
<version>0.8.1</version>
|
<version>0.8.5</version>
|
||||||
<name>LoreMind Core</name>
|
<name>LoreMind Core</name>
|
||||||
<description>Backend Core - Architecture Hexagonale</description>
|
<description>Backend Core - Architecture Hexagonale</description>
|
||||||
|
|
||||||
@@ -96,6 +96,15 @@
|
|||||||
<artifactId>bcprov-jdk18on</artifactId>
|
<artifactId>bcprov-jdk18on</artifactId>
|
||||||
<version>1.78.1</version>
|
<version>1.78.1</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- Google Tink : runtime requis par com.nimbusds.jose.crypto.Ed25519Verifier
|
||||||
|
(depuis Nimbus 9.x, la verification EdDSA delegue a Tink.subtle.Ed25519Verify).
|
||||||
|
Tink n'est PAS une dependance transitive de nimbus-jose-jwt → il faut
|
||||||
|
l'ajouter explicitement, sinon NoClassDefFoundError au premier verify(). -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.crypto.tink</groupId>
|
||||||
|
<artifactId>tink</artifactId>
|
||||||
|
<version>1.14.1</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ public class CharacterService {
|
|||||||
String headerImageId,
|
String headerImageId,
|
||||||
Map<String, String> values,
|
Map<String, String> values,
|
||||||
Map<String, List<String>> imageValues,
|
Map<String, List<String>> imageValues,
|
||||||
|
Map<String, Map<String, String>> keyValueValues,
|
||||||
String campaignId,
|
String campaignId,
|
||||||
Integer order
|
Integer order
|
||||||
) {}
|
) {}
|
||||||
@@ -46,6 +47,7 @@ public class CharacterService {
|
|||||||
.headerImageId(data.headerImageId())
|
.headerImageId(data.headerImageId())
|
||||||
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
|
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
|
||||||
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
|
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
|
||||||
|
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
|
||||||
.campaignId(data.campaignId())
|
.campaignId(data.campaignId())
|
||||||
.order(order)
|
.order(order)
|
||||||
.build();
|
.build();
|
||||||
@@ -68,6 +70,7 @@ public class CharacterService {
|
|||||||
existing.setHeaderImageId(data.headerImageId());
|
existing.setHeaderImageId(data.headerImageId());
|
||||||
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
|
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
|
||||||
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
|
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
|
||||||
|
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
|
||||||
if (data.order() != null) {
|
if (data.order() != null) {
|
||||||
existing.setOrder(data.order());
|
existing.setOrder(data.order());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public class NpcService {
|
|||||||
String headerImageId,
|
String headerImageId,
|
||||||
Map<String, String> values,
|
Map<String, String> values,
|
||||||
Map<String, List<String>> imageValues,
|
Map<String, List<String>> imageValues,
|
||||||
|
Map<String, Map<String, String>> keyValueValues,
|
||||||
String campaignId,
|
String campaignId,
|
||||||
Integer order
|
Integer order
|
||||||
) {}
|
) {}
|
||||||
@@ -41,6 +42,7 @@ public class NpcService {
|
|||||||
.headerImageId(data.headerImageId())
|
.headerImageId(data.headerImageId())
|
||||||
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
|
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
|
||||||
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
|
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
|
||||||
|
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
|
||||||
.campaignId(data.campaignId())
|
.campaignId(data.campaignId())
|
||||||
.order(order)
|
.order(order)
|
||||||
.build();
|
.build();
|
||||||
@@ -63,6 +65,7 @@ public class NpcService {
|
|||||||
existing.setHeaderImageId(data.headerImageId());
|
existing.setHeaderImageId(data.headerImageId());
|
||||||
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
|
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
|
||||||
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
|
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
|
||||||
|
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
|
||||||
if (data.order() != null) {
|
if (data.order() != null) {
|
||||||
existing.setOrder(data.order());
|
existing.setOrder(data.order());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package com.loremind.application.licensing;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestre la bascule de canal stable <-> beta via le sidecar `switcher`.
|
||||||
|
*
|
||||||
|
* <p>Le sidecar tourne en permanence et watch un fichier {@code command.json}
|
||||||
|
* dans un volume partage. Quand on depose une commande, il :
|
||||||
|
* <ol>
|
||||||
|
* <li>Sed la ligne IMAGE_NAMESPACE du .env</li>
|
||||||
|
* <li>Lance docker compose pull + up -d</li>
|
||||||
|
* <li>Ecrit son resultat dans {@code result.json}</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>Le Core n'a PAS acces au socket Docker — il delegue tout au sidecar
|
||||||
|
* via fichiers, ce qui evite que la compromission du Core ne donne RCE
|
||||||
|
* sur l'hote. Le sidecar valide strictement le contenu de la commande
|
||||||
|
* (channel ∈ {stable, beta} uniquement).
|
||||||
|
*
|
||||||
|
* <p>Le canal actuel se deduit du prefixe d'image courant (recupere via
|
||||||
|
* la variable d'env {@code IMAGE_NAMESPACE} ou {@code UPDATE_CHECK_IMAGES}) :
|
||||||
|
* presence de "loremind-beta-" => canal beta, sinon stable.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ChannelSwitcherService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ChannelSwitcherService.class);
|
||||||
|
|
||||||
|
public enum Channel { STABLE, BETA }
|
||||||
|
|
||||||
|
public enum SwitchStatus { IN_PROGRESS, SUCCESS, ERROR }
|
||||||
|
|
||||||
|
/** Snapshot du dernier resultat de switch ecrit par le sidecar. */
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public record SwitchResult(
|
||||||
|
String id,
|
||||||
|
SwitchStatus status,
|
||||||
|
Channel channel,
|
||||||
|
String message,
|
||||||
|
Instant completedAt) {}
|
||||||
|
|
||||||
|
private final Path switcherDataPath;
|
||||||
|
private final String imageNamespace;
|
||||||
|
private final ObjectMapper json = new ObjectMapper();
|
||||||
|
|
||||||
|
public ChannelSwitcherService(
|
||||||
|
@Value("${SWITCHER_DATA_PATH:/shared/switcher}") String switcherDataPath,
|
||||||
|
// On lit IMAGE_NAMESPACE en priorite, puis UPDATE_CHECK_IMAGES en fallback
|
||||||
|
// (la deuxieme est toujours injectee par compose, contrairement a la premiere
|
||||||
|
// qui peut etre absente dans les .env legacy).
|
||||||
|
@Value("${IMAGE_NAMESPACE:${UPDATE_CHECK_IMAGES:}}") String imageNamespaceRaw) {
|
||||||
|
this.switcherDataPath = Path.of(switcherDataPath);
|
||||||
|
this.imageNamespace = imageNamespaceRaw != null ? imageNamespaceRaw : "";
|
||||||
|
log.info("ChannelSwitcherService initialized: dataPath={} imageNamespace={}",
|
||||||
|
switcherDataPath, this.imageNamespace);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detection du canal courant a partir du prefixe d'image charge au demarrage.
|
||||||
|
* Pas de magie : si le namespace contient "beta-" on est en beta, sinon stable.
|
||||||
|
*/
|
||||||
|
public Channel getCurrentChannel() {
|
||||||
|
return imageNamespace.contains("loremind-beta-") ? Channel.BETA : Channel.STABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indique si le sidecar est disponible (volume partage accessible).
|
||||||
|
* Si non, on degrade en lecture seule (l'UI affichera l'ancien message
|
||||||
|
* avec instructions manuelles).
|
||||||
|
*/
|
||||||
|
public boolean isSwitcherAvailable() {
|
||||||
|
return Files.isDirectory(switcherDataPath) && Files.isWritable(switcherDataPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Depose une commande de switch dans le volume partage. Renvoie l'ID
|
||||||
|
* de la commande, que le client peut utiliser pour poller le status.
|
||||||
|
*
|
||||||
|
* @throws IllegalStateException si le sidecar n'est pas disponible
|
||||||
|
* @throws IOException si l'ecriture du fichier echoue
|
||||||
|
*/
|
||||||
|
public String requestSwitch(Channel target) throws IOException {
|
||||||
|
if (!isSwitcherAvailable()) {
|
||||||
|
throw new IllegalStateException("Switcher sidecar not available (volume mount missing)");
|
||||||
|
}
|
||||||
|
String id = UUID.randomUUID().toString();
|
||||||
|
Map<String, Object> command = new LinkedHashMap<>();
|
||||||
|
command.put("id", id);
|
||||||
|
command.put("channel", target.name().toLowerCase());
|
||||||
|
command.put("requestedAt", Instant.now().toString());
|
||||||
|
|
||||||
|
Path commandFile = switcherDataPath.resolve("command.json");
|
||||||
|
Path tmp = Files.createTempFile(switcherDataPath, "command-", ".tmp");
|
||||||
|
try {
|
||||||
|
json.writerWithDefaultPrettyPrinter().writeValue(tmp.toFile(), command);
|
||||||
|
// Atomic move : evite que le sidecar lise un fichier partiellement ecrit.
|
||||||
|
Files.move(tmp, commandFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||||
|
} finally {
|
||||||
|
// Cleanup au cas ou move aurait echoue avant le rename.
|
||||||
|
Files.deleteIfExists(tmp);
|
||||||
|
}
|
||||||
|
log.info("Switch command written: id={} channel={}", id, target);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lit le dernier resultat ecrit par le sidecar, s'il existe.
|
||||||
|
* Renvoie null si aucun switch n'a encore ete tente sur cette instance.
|
||||||
|
*/
|
||||||
|
public SwitchResult getLastResult() {
|
||||||
|
Path resultFile = switcherDataPath.resolve("result.json");
|
||||||
|
if (!Files.exists(resultFile)) return null;
|
||||||
|
try {
|
||||||
|
return json.readValue(resultFile.toFile(), SwitchResult.class);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("Cannot parse switcher result.json: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,14 @@ public class Character {
|
|||||||
*/
|
*/
|
||||||
private Map<String, List<String>> imageValues;
|
private Map<String, List<String>> imageValues;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valeurs des champs KEY_VALUE_LIST du template PJ. Cle externe = nom du
|
||||||
|
* champ template (ex: "Caracteristiques"), cle interne = label predefini
|
||||||
|
* dans le template (ex: "FOR"), valeur = valeur saisie (ex: "16").
|
||||||
|
* Les labels suivent l'ordre defini dans TemplateField.labels.
|
||||||
|
*/
|
||||||
|
private Map<String, Map<String, String>> keyValueValues;
|
||||||
|
|
||||||
/** Référence vers la Campaign parente. */
|
/** Référence vers la Campaign parente. */
|
||||||
private String campaignId;
|
private String campaignId;
|
||||||
|
|
||||||
@@ -70,4 +78,9 @@ public class Character {
|
|||||||
if (imageValues == null) imageValues = new HashMap<>();
|
if (imageValues == null) imageValues = new HashMap<>();
|
||||||
return imageValues;
|
return imageValues;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Map<String, Map<String, String>> getKeyValueValues() {
|
||||||
|
if (keyValueValues == null) keyValueValues = new HashMap<>();
|
||||||
|
return keyValueValues;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ public class Npc {
|
|||||||
/** Valeurs IMAGE du template PNJ (listes d'IDs ordonnees par champ). Jamais null. */
|
/** Valeurs IMAGE du template PNJ (listes d'IDs ordonnees par champ). Jamais null. */
|
||||||
private Map<String, List<String>> imageValues;
|
private Map<String, List<String>> imageValues;
|
||||||
|
|
||||||
|
/** Valeurs KEY_VALUE_LIST : fieldName -> label -> value. Jamais null. */
|
||||||
|
private Map<String, Map<String, String>> keyValueValues;
|
||||||
|
|
||||||
/** Référence vers la Campaign parente (cross-aggregate via ID). */
|
/** Référence vers la Campaign parente (cross-aggregate via ID). */
|
||||||
private String campaignId;
|
private String campaignId;
|
||||||
|
|
||||||
@@ -58,4 +61,9 @@ public class Npc {
|
|||||||
if (imageValues == null) imageValues = new HashMap<>();
|
if (imageValues == null) imageValues = new HashMap<>();
|
||||||
return imageValues;
|
return imageValues;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Map<String, Map<String, String>> getKeyValueValues() {
|
||||||
|
if (keyValueValues == null) keyValueValues = new HashMap<>();
|
||||||
|
return keyValueValues;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,18 @@ package com.loremind.domain.shared.template;
|
|||||||
/**
|
/**
|
||||||
* Type d'un champ dynamique de template (kernel partage).
|
* Type d'un champ dynamique de template (kernel partage).
|
||||||
* <p>
|
* <p>
|
||||||
* - TEXT : valeur textuelle libre (Map<String, String>)
|
* - TEXT : valeur textuelle libre (Map<String, String>)
|
||||||
* - IMAGE : galerie d'images, liste d'IDs (Map<String, List<String>>)
|
* - IMAGE : galerie d'images, liste d'IDs (Map<String, List<String>>)
|
||||||
* - NUMBER : valeur numerique stockee en texte (parsee a l'usage)
|
* - NUMBER : valeur numerique stockee en texte (parsee a l'usage)
|
||||||
|
* - KEY_VALUE_LIST : liste de paires {label, value} avec labels figes au template
|
||||||
|
* (Map<String, Map<String, String>> : fieldName -> label -> value).
|
||||||
|
* Usage : stat blocks, listes de competences, traits.
|
||||||
* <p>
|
* <p>
|
||||||
* Extension future possible : RICH_TEXT, DATE, BOOLEAN, KEY_VALUE_LIST, REFERENCE...
|
* Extension future possible : RICH_TEXT, DATE, BOOLEAN, REFERENCE...
|
||||||
*/
|
*/
|
||||||
public enum FieldType {
|
public enum FieldType {
|
||||||
TEXT,
|
TEXT,
|
||||||
IMAGE,
|
IMAGE,
|
||||||
NUMBER
|
NUMBER,
|
||||||
|
KEY_VALUE_LIST
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import lombok.Builder;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Value Object d'un champ de Template (kernel partage).
|
* Value Object d'un champ de Template (kernel partage).
|
||||||
* <p>
|
* <p>
|
||||||
@@ -27,29 +29,45 @@ public class TemplateField {
|
|||||||
private FieldType type;
|
private FieldType type;
|
||||||
/** Variante de rendu pour les champs IMAGE. Null = GALLERY. */
|
/** Variante de rendu pour les champs IMAGE. Null = GALLERY. */
|
||||||
private ImageLayout layout;
|
private ImageLayout layout;
|
||||||
|
/**
|
||||||
|
* Labels predefinis pour les champs KEY_VALUE_LIST (ordre significatif).
|
||||||
|
* Ex: ["FOR","DEX","CON","INT","SAG","CHA"] pour un champ "Caracteristiques".
|
||||||
|
* Null/vide pour les autres types.
|
||||||
|
*/
|
||||||
|
private List<String> labels;
|
||||||
|
|
||||||
/** Constructeur de retrocompat : type seul, layout=null. */
|
/** Constructeur de retrocompat : type seul, layout/labels=null. */
|
||||||
public TemplateField(String name, FieldType type) {
|
public TemplateField(String name, FieldType type) {
|
||||||
this(name, type, null);
|
this(name, type, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Constructeur de retrocompat : type + layout, labels=null. */
|
||||||
|
public TemplateField(String name, FieldType type, ImageLayout layout) {
|
||||||
|
this(name, type, layout, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Raccourci : construit un champ de type TEXT (cas le plus courant). */
|
/** Raccourci : construit un champ de type TEXT (cas le plus courant). */
|
||||||
public static TemplateField text(String name) {
|
public static TemplateField text(String name) {
|
||||||
return new TemplateField(name, FieldType.TEXT, null);
|
return new TemplateField(name, FieldType.TEXT, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Raccourci : construit un champ de type IMAGE avec layout GALLERY. */
|
/** Raccourci : construit un champ de type IMAGE avec layout GALLERY. */
|
||||||
public static TemplateField image(String name) {
|
public static TemplateField image(String name) {
|
||||||
return new TemplateField(name, FieldType.IMAGE, ImageLayout.GALLERY);
|
return new TemplateField(name, FieldType.IMAGE, ImageLayout.GALLERY, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Raccourci : construit un champ IMAGE avec un layout specifique. */
|
/** Raccourci : construit un champ IMAGE avec un layout specifique. */
|
||||||
public static TemplateField image(String name, ImageLayout layout) {
|
public static TemplateField image(String name, ImageLayout layout) {
|
||||||
return new TemplateField(name, FieldType.IMAGE, layout);
|
return new TemplateField(name, FieldType.IMAGE, layout, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Raccourci : construit un champ de type NUMBER. */
|
/** Raccourci : construit un champ de type NUMBER. */
|
||||||
public static TemplateField number(String name) {
|
public static TemplateField number(String name) {
|
||||||
return new TemplateField(name, FieldType.NUMBER, null);
|
return new TemplateField(name, FieldType.NUMBER, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raccourci : construit un champ KEY_VALUE_LIST avec labels predefinis. */
|
||||||
|
public static TemplateField keyValueList(String name, List<String> labels) {
|
||||||
|
return new TemplateField(name, FieldType.KEY_VALUE_LIST, null, labels);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,12 +152,8 @@ public class GameSystemSeeder {
|
|||||||
TemplateField.number("Niveau"),
|
TemplateField.number("Niveau"),
|
||||||
TemplateField.number("PV max"),
|
TemplateField.number("PV max"),
|
||||||
TemplateField.number("CA"),
|
TemplateField.number("CA"),
|
||||||
TemplateField.number("FOR"),
|
TemplateField.keyValueList("Caracteristiques",
|
||||||
TemplateField.number("DEX"),
|
List.of("FOR", "DEX", "CON", "INT", "SAG", "CHA")),
|
||||||
TemplateField.number("CON"),
|
|
||||||
TemplateField.number("INT"),
|
|
||||||
TemplateField.number("SAG"),
|
|
||||||
TemplateField.number("CHA"),
|
|
||||||
TemplateField.text("Competences"),
|
TemplateField.text("Competences"),
|
||||||
TemplateField.text("Equipement"),
|
TemplateField.text("Equipement"),
|
||||||
TemplateField.text("Sorts"),
|
TemplateField.text("Sorts"),
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.loremind.infrastructure.persistence.converter;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.persistence.AttributeConverter;
|
||||||
|
import jakarta.persistence.Converter;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit une Map<String, Map<String, String>> en JSON et inversement.
|
||||||
|
* <p>
|
||||||
|
* Utilise pour Character/Npc.keyValueValues : pour chaque champ KEY_VALUE_LIST
|
||||||
|
* du template, stocke une map label -> value. Exemple :
|
||||||
|
* {"Caracteristiques": {"FOR":"16","DEX":"12","CON":"14"}}
|
||||||
|
* <p>
|
||||||
|
* Adaptateur technique pur : le domaine ignore ce converter.
|
||||||
|
*/
|
||||||
|
@Converter
|
||||||
|
public class StringMapMapJsonConverter
|
||||||
|
implements AttributeConverter<Map<String, Map<String, String>>, String> {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
private static final TypeReference<Map<String, Map<String, String>>> TYPE_REF =
|
||||||
|
new TypeReference<>() {};
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String convertToDatabaseColumn(Map<String, Map<String, String>> attribute) {
|
||||||
|
if (attribute == null || attribute.isEmpty()) return "{}";
|
||||||
|
try {
|
||||||
|
return MAPPER.writeValueAsString(attribute);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Erreur serialisation Map<String, Map<String,String>> -> JSON", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Map<String, String>> convertToEntityAttribute(String dbData) {
|
||||||
|
if (dbData == null || dbData.isBlank()) return Collections.emptyMap();
|
||||||
|
try {
|
||||||
|
return MAPPER.readValue(dbData, TYPE_REF);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Erreur deserialisation JSON -> Map<String, Map<String,String>>", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,8 +85,18 @@ public class TemplateFieldListJsonConverter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
List<String> labels = null;
|
||||||
|
if (type == FieldType.KEY_VALUE_LIST) {
|
||||||
|
JsonNode labelsNode = item.path("labels");
|
||||||
|
if (labelsNode.isArray()) {
|
||||||
|
labels = new ArrayList<>();
|
||||||
|
for (JsonNode label : labelsNode) {
|
||||||
|
if (label.isTextual()) labels.add(label.asText());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (name != null && !name.isBlank()) {
|
if (name != null && !name.isBlank()) {
|
||||||
result.add(new TemplateField(name, type, layout));
|
result.add(new TemplateField(name, type, layout, labels));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Autres types de noeuds (nombre, booleen...) : ignores silencieusement.
|
// Autres types de noeuds (nombre, booleen...) : ignores silencieusement.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.loremind.infrastructure.persistence.entity;
|
|||||||
|
|
||||||
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
|
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
|
||||||
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
|
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
|
||||||
|
import com.loremind.infrastructure.persistence.converter.StringMapMapJsonConverter;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
@@ -53,6 +54,11 @@ public class CharacterJpaEntity {
|
|||||||
@Column(name = "image_values", columnDefinition = "TEXT")
|
@Column(name = "image_values", columnDefinition = "TEXT")
|
||||||
private Map<String, List<String>> imageValues;
|
private Map<String, List<String>> imageValues;
|
||||||
|
|
||||||
|
/** Valeurs KEY_VALUE_LIST serialisees JSON (fieldName -> label -> value). */
|
||||||
|
@Convert(converter = StringMapMapJsonConverter.class)
|
||||||
|
@Column(name = "key_value_values", columnDefinition = "TEXT")
|
||||||
|
private Map<String, Map<String, String>> keyValueValues;
|
||||||
|
|
||||||
@Column(name = "campaign_id", nullable = false)
|
@Column(name = "campaign_id", nullable = false)
|
||||||
private Long campaignId;
|
private Long campaignId;
|
||||||
|
|
||||||
@@ -71,6 +77,7 @@ public class CharacterJpaEntity {
|
|||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now();
|
||||||
if (values == null) values = new HashMap<>();
|
if (values == null) values = new HashMap<>();
|
||||||
if (imageValues == null) imageValues = new HashMap<>();
|
if (imageValues == null) imageValues = new HashMap<>();
|
||||||
|
if (keyValueValues == null) keyValueValues = new HashMap<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.loremind.infrastructure.persistence.entity;
|
|||||||
|
|
||||||
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
|
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
|
||||||
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
|
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
|
||||||
|
import com.loremind.infrastructure.persistence.converter.StringMapMapJsonConverter;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
@@ -46,6 +47,10 @@ public class NpcJpaEntity {
|
|||||||
@Column(name = "image_values", columnDefinition = "TEXT")
|
@Column(name = "image_values", columnDefinition = "TEXT")
|
||||||
private Map<String, List<String>> imageValues;
|
private Map<String, List<String>> imageValues;
|
||||||
|
|
||||||
|
@Convert(converter = StringMapMapJsonConverter.class)
|
||||||
|
@Column(name = "key_value_values", columnDefinition = "TEXT")
|
||||||
|
private Map<String, Map<String, String>> keyValueValues;
|
||||||
|
|
||||||
@Column(name = "campaign_id", nullable = false)
|
@Column(name = "campaign_id", nullable = false)
|
||||||
private Long campaignId;
|
private Long campaignId;
|
||||||
|
|
||||||
@@ -64,6 +69,7 @@ public class NpcJpaEntity {
|
|||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now();
|
||||||
if (values == null) values = new HashMap<>();
|
if (values == null) values = new HashMap<>();
|
||||||
if (imageValues == null) imageValues = new HashMap<>();
|
if (imageValues == null) imageValues = new HashMap<>();
|
||||||
|
if (keyValueValues == null) keyValueValues = new HashMap<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ public class PostgresCharacterRepository implements CharacterRepository {
|
|||||||
.headerImageId(e.getHeaderImageId())
|
.headerImageId(e.getHeaderImageId())
|
||||||
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
|
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
|
||||||
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
|
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
|
||||||
|
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
|
||||||
.campaignId(e.getCampaignId().toString())
|
.campaignId(e.getCampaignId().toString())
|
||||||
.order(e.getOrder())
|
.order(e.getOrder())
|
||||||
.createdAt(e.getCreatedAt())
|
.createdAt(e.getCreatedAt())
|
||||||
@@ -73,6 +74,7 @@ public class PostgresCharacterRepository implements CharacterRepository {
|
|||||||
.headerImageId(c.getHeaderImageId())
|
.headerImageId(c.getHeaderImageId())
|
||||||
.values(c.getValues() != null ? new HashMap<>(c.getValues()) : new HashMap<>())
|
.values(c.getValues() != null ? new HashMap<>(c.getValues()) : new HashMap<>())
|
||||||
.imageValues(c.getImageValues() != null ? new HashMap<>(c.getImageValues()) : new HashMap<>())
|
.imageValues(c.getImageValues() != null ? new HashMap<>(c.getImageValues()) : new HashMap<>())
|
||||||
|
.keyValueValues(c.getKeyValueValues() != null ? new HashMap<>(c.getKeyValueValues()) : new HashMap<>())
|
||||||
.campaignId(Long.parseLong(c.getCampaignId()))
|
.campaignId(Long.parseLong(c.getCampaignId()))
|
||||||
.order(c.getOrder())
|
.order(c.getOrder())
|
||||||
.createdAt(c.getCreatedAt())
|
.createdAt(c.getCreatedAt())
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ public class PostgresNpcRepository implements NpcRepository {
|
|||||||
.headerImageId(e.getHeaderImageId())
|
.headerImageId(e.getHeaderImageId())
|
||||||
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
|
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
|
||||||
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
|
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
|
||||||
|
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
|
||||||
.campaignId(e.getCampaignId().toString())
|
.campaignId(e.getCampaignId().toString())
|
||||||
.order(e.getOrder())
|
.order(e.getOrder())
|
||||||
.createdAt(e.getCreatedAt())
|
.createdAt(e.getCreatedAt())
|
||||||
@@ -73,6 +74,7 @@ public class PostgresNpcRepository implements NpcRepository {
|
|||||||
.headerImageId(n.getHeaderImageId())
|
.headerImageId(n.getHeaderImageId())
|
||||||
.values(n.getValues() != null ? new HashMap<>(n.getValues()) : new HashMap<>())
|
.values(n.getValues() != null ? new HashMap<>(n.getValues()) : new HashMap<>())
|
||||||
.imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>())
|
.imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>())
|
||||||
|
.keyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>())
|
||||||
.campaignId(Long.parseLong(n.getCampaignId()))
|
.campaignId(Long.parseLong(n.getCampaignId()))
|
||||||
.order(n.getOrder())
|
.order(n.getOrder())
|
||||||
.createdAt(n.getCreatedAt())
|
.createdAt(n.getCreatedAt())
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package com.loremind.infrastructure.web;
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityNotFoundException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercepteur global d'exceptions pour TOUS les @RestController.
|
||||||
|
*
|
||||||
|
* <p>Role :
|
||||||
|
* <ul>
|
||||||
|
* <li>Logger systematiquement les exceptions non gerees (avec stack trace + path)
|
||||||
|
* — evite d'avoir a creuser dans les logs Docker apres coup.</li>
|
||||||
|
* <li>Renvoyer un JSON propre au client (`{error, type, ...}`) au lieu du 500 nu
|
||||||
|
* par defaut de Spring — utile pour debug cote frontend (visible directement
|
||||||
|
* dans la DevTools reseau).</li>
|
||||||
|
* <li>Mapper les exceptions courantes vers des status HTTP appropries
|
||||||
|
* (IllegalArgumentException -> 400, EntityNotFoundException -> 404).</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Important : ne court-circuite PAS les try/catch locaux des controllers
|
||||||
|
* (ex: LicenseController.install catche InstallException -> 400 lui-meme).
|
||||||
|
* Ce handler n'attrape QUE ce qui a echappe au catch local.
|
||||||
|
*/
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Violation d'invariant domaine (doublons, valeurs invalides, etc.) -> 400.
|
||||||
|
* Concentre ici la logique qui etait dupliquee dans GameSystemController.
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(IllegalArgumentException.class)
|
||||||
|
public ResponseEntity<Map<String, String>> handleIllegalArgument(IllegalArgumentException ex) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", safeMessage(ex)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entite JPA introuvable -> 404. */
|
||||||
|
@ExceptionHandler(EntityNotFoundException.class)
|
||||||
|
public ResponseEntity<Map<String, String>> handleNotFound(EntityNotFoundException ex) {
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", safeMessage(ex)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON malforme dans le body de la requete -> 400. */
|
||||||
|
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||||
|
public ResponseEntity<Map<String, String>> handleUnreadable(HttpMessageNotReadableException ex) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "Malformed request body"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validation @Valid echouee -> 400 avec liste des erreurs par champ. */
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
|
||||||
|
Map<String, String> fields = new LinkedHashMap<>();
|
||||||
|
ex.getBindingResult().getFieldErrors().forEach(e ->
|
||||||
|
fields.put(e.getField(), e.getDefaultMessage() != null ? e.getDefaultMessage() : "invalid"));
|
||||||
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
|
"error", "Validation failed",
|
||||||
|
"fields", fields
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback : tout ce qui n'a pas ete catche au-dessus -> 500, mais avec
|
||||||
|
* un log ERROR explicite (path + stack trace) et un body JSON debuggable
|
||||||
|
* cote client. C'est LE filet de securite.
|
||||||
|
*
|
||||||
|
* Note : on attrape Throwable (pas Exception) pour aussi capturer les
|
||||||
|
* Error (NoClassDefFoundError, OutOfMemoryError... — cf. incident Tink).
|
||||||
|
* On NE swallow PAS — on log AVANT de renvoyer une reponse.
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(Throwable.class)
|
||||||
|
public ResponseEntity<Map<String, String>> handleUnexpected(HttpServletRequest request, Throwable ex) {
|
||||||
|
log.error("Unhandled exception on {} {}", request.getMethod(), request.getRequestURI(), ex);
|
||||||
|
Map<String, String> body = new LinkedHashMap<>();
|
||||||
|
body.put("error", "Internal server error");
|
||||||
|
body.put("type", ex.getClass().getSimpleName());
|
||||||
|
String msg = safeMessage(ex);
|
||||||
|
if (!msg.isEmpty()) body.put("message", msg);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Evite les NPE quand getMessage() est null sur certaines exceptions. */
|
||||||
|
private static String safeMessage(Throwable ex) {
|
||||||
|
return ex.getMessage() != null ? ex.getMessage() : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@ public class CharacterController {
|
|||||||
dto.getHeaderImageId(),
|
dto.getHeaderImageId(),
|
||||||
dto.getValues(),
|
dto.getValues(),
|
||||||
dto.getImageValues(),
|
dto.getImageValues(),
|
||||||
|
dto.getKeyValueValues(),
|
||||||
dto.getCampaignId(),
|
dto.getCampaignId(),
|
||||||
order
|
order
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
|
|||||||
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||||
import com.loremind.infrastructure.web.mapper.GameSystemMapper;
|
import com.loremind.infrastructure.web.mapper.GameSystemMapper;
|
||||||
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
|
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@@ -72,12 +71,6 @@ public class GameSystemController {
|
|||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mappe les violations d'invariants domaine (doublons de champs, etc.) en 400. */
|
|
||||||
@ExceptionHandler(IllegalArgumentException.class)
|
|
||||||
public ResponseEntity<String> onIllegalArgument(IllegalArgumentException ex) {
|
|
||||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ex.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
private GameSystemService.GameSystemData toData(GameSystemDTO dto) {
|
private GameSystemService.GameSystemData toData(GameSystemDTO dto) {
|
||||||
return new GameSystemService.GameSystemData(
|
return new GameSystemService.GameSystemData(
|
||||||
dto.getName(),
|
dto.getName(),
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
package com.loremind.infrastructure.web.controller;
|
package com.loremind.infrastructure.web.controller;
|
||||||
|
|
||||||
|
import com.loremind.application.licensing.ChannelSwitcherService;
|
||||||
import com.loremind.application.licensing.LicenseService;
|
import com.loremind.application.licensing.LicenseService;
|
||||||
import com.loremind.application.licensing.LicenseService.InstallException;
|
import com.loremind.application.licensing.LicenseService.InstallException;
|
||||||
import com.loremind.domain.licensing.LicenseSnapshot;
|
import com.loremind.domain.licensing.LicenseSnapshot;
|
||||||
|
import com.loremind.infrastructure.web.dto.licensing.ChannelStatusDTO;
|
||||||
import com.loremind.infrastructure.web.dto.licensing.LicenseStatusDTO;
|
import com.loremind.infrastructure.web.dto.licensing.LicenseStatusDTO;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,9 +30,11 @@ import java.util.Map;
|
|||||||
public class LicenseController {
|
public class LicenseController {
|
||||||
|
|
||||||
private final LicenseService licenseService;
|
private final LicenseService licenseService;
|
||||||
|
private final ChannelSwitcherService channelSwitcher;
|
||||||
|
|
||||||
public LicenseController(LicenseService licenseService) {
|
public LicenseController(LicenseService licenseService, ChannelSwitcherService channelSwitcher) {
|
||||||
this.licenseService = licenseService;
|
this.licenseService = licenseService;
|
||||||
|
this.channelSwitcher = channelSwitcher;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -82,6 +88,68 @@ public class LicenseController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Bascule de canal (stable <-> beta) via sidecar switcher ────────────
|
||||||
|
//
|
||||||
|
// Le flux :
|
||||||
|
// 1. UI POST /api/license/channel/switch { channel: "beta" }
|
||||||
|
// 2. Core valide la licence (refus si target=beta sans Patreon actif)
|
||||||
|
// 3. Core depose une commande dans le volume partage
|
||||||
|
// 4. Sidecar `switcher` la traite (sed .env, docker compose up -d)
|
||||||
|
// 5. UI poll GET /api/license/channel pour suivre le status
|
||||||
|
|
||||||
|
/** Etat courant : canal actuel + dispo du sidecar + dernier resultat. */
|
||||||
|
@GetMapping("/channel")
|
||||||
|
public ChannelStatusDTO getChannel() {
|
||||||
|
return ChannelStatusDTO.from(channelSwitcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Declenche un switch de canal. Renvoie l'ID de la commande pour le polling. */
|
||||||
|
@PostMapping("/channel/switch")
|
||||||
|
public ResponseEntity<?> switchChannel(@RequestBody ChannelSwitchRequest request) {
|
||||||
|
if (request == null || request.channel() == null) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "missing channel"));
|
||||||
|
}
|
||||||
|
|
||||||
|
ChannelSwitcherService.Channel target;
|
||||||
|
try {
|
||||||
|
target = ChannelSwitcherService.Channel.valueOf(request.channel().toUpperCase(Locale.ROOT));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
|
"error", "invalid channel (allowed: stable, beta)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garde : pas de switch vers beta sans licence Patreon valide.
|
||||||
|
// Le switcher ferait le boulot quoi qu'il arrive (il valide juste le
|
||||||
|
// format), donc c'est ici qu'on doit refuser cote metier.
|
||||||
|
// VALID + GRACE autorisent l'acces beta (cf. javadoc de LicenseStatus).
|
||||||
|
if (target == ChannelSwitcherService.Channel.BETA) {
|
||||||
|
LicenseSnapshot snap = licenseService.getCurrentSnapshot();
|
||||||
|
com.loremind.domain.licensing.LicenseStatus s = (snap != null) ? snap.status() : null;
|
||||||
|
boolean allowed = s == com.loremind.domain.licensing.LicenseStatus.VALID
|
||||||
|
|| s == com.loremind.domain.licensing.LicenseStatus.GRACE;
|
||||||
|
if (!allowed) {
|
||||||
|
return ResponseEntity.status(403).body(Map.of(
|
||||||
|
"error", "Aucune licence Patreon active — impossible de basculer sur le canal beta."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!channelSwitcher.isSwitcherAvailable()) {
|
||||||
|
return ResponseEntity.status(503).body(Map.of(
|
||||||
|
"error", "Sidecar switcher non disponible (mise a jour requise du docker-compose.yml)."));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String id = channelSwitcher.requestSwitch(target);
|
||||||
|
return ResponseEntity.accepted().body(Map.of(
|
||||||
|
"id", id,
|
||||||
|
"channel", target.name().toLowerCase(Locale.ROOT)));
|
||||||
|
} catch (IOException e) {
|
||||||
|
return ResponseEntity.status(500).body(Map.of(
|
||||||
|
"error", "Impossible d'ecrire la commande de switch: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public record InstallRequest(String jwt) {}
|
public record InstallRequest(String jwt) {}
|
||||||
public record BetaChannelRequest(boolean enabled) {}
|
public record BetaChannelRequest(boolean enabled) {}
|
||||||
|
public record ChannelSwitchRequest(String channel) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ public class NpcController {
|
|||||||
dto.getHeaderImageId(),
|
dto.getHeaderImageId(),
|
||||||
dto.getValues(),
|
dto.getValues(),
|
||||||
dto.getImageValues(),
|
dto.getImageValues(),
|
||||||
|
dto.getKeyValueValues(),
|
||||||
dto.getCampaignId(),
|
dto.getCampaignId(),
|
||||||
order
|
order
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public class CharacterDTO {
|
|||||||
private String headerImageId;
|
private String headerImageId;
|
||||||
private Map<String, String> values = new HashMap<>();
|
private Map<String, String> values = new HashMap<>();
|
||||||
private Map<String, List<String>> imageValues = new HashMap<>();
|
private Map<String, List<String>> imageValues = new HashMap<>();
|
||||||
|
private Map<String, Map<String, String>> keyValueValues = new HashMap<>();
|
||||||
private String campaignId;
|
private String campaignId;
|
||||||
private int order;
|
private int order;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public class NpcDTO {
|
|||||||
private String headerImageId;
|
private String headerImageId;
|
||||||
private Map<String, String> values = new HashMap<>();
|
private Map<String, String> values = new HashMap<>();
|
||||||
private Map<String, List<String>> imageValues = new HashMap<>();
|
private Map<String, List<String>> imageValues = new HashMap<>();
|
||||||
|
private Map<String, Map<String, String>> keyValueValues = new HashMap<>();
|
||||||
private String campaignId;
|
private String campaignId;
|
||||||
private int order;
|
private int order;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.loremind.infrastructure.web.dto.licensing;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import com.loremind.application.licensing.ChannelSwitcherService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Etat du canal courant + dernier resultat de switch.
|
||||||
|
*
|
||||||
|
* <p>{@code currentChannel} : detecte au demarrage de Core a partir du prefixe
|
||||||
|
* d'image. {@code switcherAvailable} : indique si le sidecar de switch est
|
||||||
|
* monte (V0.9+) ou si on est sur une vieille install qui doit encore passer
|
||||||
|
* par les instructions manuelles.
|
||||||
|
*
|
||||||
|
* <p>{@code lastSwitch} : null tant qu'aucun switch n'a ete tente sur cette
|
||||||
|
* instance. Sinon, contient le resultat du dernier appel (en cours / succes /
|
||||||
|
* erreur), utilise par l'UI pour suivre la progression apres clic.
|
||||||
|
*/
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public record ChannelStatusDTO(
|
||||||
|
String currentChannel,
|
||||||
|
boolean switcherAvailable,
|
||||||
|
ChannelSwitcherService.SwitchResult lastSwitch) {
|
||||||
|
|
||||||
|
public static ChannelStatusDTO from(ChannelSwitcherService service) {
|
||||||
|
return new ChannelStatusDTO(
|
||||||
|
service.getCurrentChannel().name().toLowerCase(),
|
||||||
|
service.isSwitcherAvailable(),
|
||||||
|
service.getLastResult());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import lombok.AllArgsConstructor;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DTO pour un champ de Template.
|
* DTO pour un champ de Template.
|
||||||
* <p>
|
* <p>
|
||||||
@@ -17,13 +19,20 @@ import lombok.NoArgsConstructor;
|
|||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public class TemplateFieldDTO {
|
public class TemplateFieldDTO {
|
||||||
private String name;
|
private String name;
|
||||||
/** "TEXT" ou "IMAGE" (string pour serialisation JSON transparente). */
|
/** "TEXT" | "IMAGE" | "NUMBER" | "KEY_VALUE_LIST". */
|
||||||
private String type;
|
private String type;
|
||||||
/** "GALLERY" | "HERO" | "MASONRY" | "CAROUSEL", null si type=TEXT. */
|
/** "GALLERY" | "HERO" | "MASONRY" | "CAROUSEL", uniquement pour IMAGE. */
|
||||||
private String layout;
|
private String layout;
|
||||||
|
/** Labels predefinis pour KEY_VALUE_LIST (ordre significatif). */
|
||||||
|
private List<String> labels;
|
||||||
|
|
||||||
/** Retrocompat : constructeur sans layout. */
|
/** Retrocompat : constructeur sans labels. */
|
||||||
|
public TemplateFieldDTO(String name, String type, String layout) {
|
||||||
|
this(name, type, layout, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retrocompat : constructeur sans layout ni labels. */
|
||||||
public TemplateFieldDTO(String name, String type) {
|
public TemplateFieldDTO(String name, String type) {
|
||||||
this(name, type, null);
|
this(name, type, null, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public class CharacterMapper {
|
|||||||
dto.setHeaderImageId(c.getHeaderImageId());
|
dto.setHeaderImageId(c.getHeaderImageId());
|
||||||
dto.setValues(c.getValues() != null ? new HashMap<>(c.getValues()) : new HashMap<>());
|
dto.setValues(c.getValues() != null ? new HashMap<>(c.getValues()) : new HashMap<>());
|
||||||
dto.setImageValues(c.getImageValues() != null ? new HashMap<>(c.getImageValues()) : new HashMap<>());
|
dto.setImageValues(c.getImageValues() != null ? new HashMap<>(c.getImageValues()) : new HashMap<>());
|
||||||
|
dto.setKeyValueValues(c.getKeyValueValues() != null ? new HashMap<>(c.getKeyValueValues()) : new HashMap<>());
|
||||||
dto.setCampaignId(c.getCampaignId());
|
dto.setCampaignId(c.getCampaignId());
|
||||||
dto.setOrder(c.getOrder());
|
dto.setOrder(c.getOrder());
|
||||||
return dto;
|
return dto;
|
||||||
@@ -32,6 +33,7 @@ public class CharacterMapper {
|
|||||||
.headerImageId(dto.getHeaderImageId())
|
.headerImageId(dto.getHeaderImageId())
|
||||||
.values(dto.getValues() != null ? new HashMap<>(dto.getValues()) : new HashMap<>())
|
.values(dto.getValues() != null ? new HashMap<>(dto.getValues()) : new HashMap<>())
|
||||||
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
|
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
|
||||||
|
.keyValueValues(dto.getKeyValueValues() != null ? new HashMap<>(dto.getKeyValueValues()) : new HashMap<>())
|
||||||
.campaignId(dto.getCampaignId())
|
.campaignId(dto.getCampaignId())
|
||||||
.order(dto.getOrder())
|
.order(dto.getOrder())
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public class NpcMapper {
|
|||||||
dto.setHeaderImageId(n.getHeaderImageId());
|
dto.setHeaderImageId(n.getHeaderImageId());
|
||||||
dto.setValues(n.getValues() != null ? new HashMap<>(n.getValues()) : new HashMap<>());
|
dto.setValues(n.getValues() != null ? new HashMap<>(n.getValues()) : new HashMap<>());
|
||||||
dto.setImageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>());
|
dto.setImageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>());
|
||||||
|
dto.setKeyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>());
|
||||||
dto.setCampaignId(n.getCampaignId());
|
dto.setCampaignId(n.getCampaignId());
|
||||||
dto.setOrder(n.getOrder());
|
dto.setOrder(n.getOrder());
|
||||||
return dto;
|
return dto;
|
||||||
@@ -32,6 +33,7 @@ public class NpcMapper {
|
|||||||
.headerImageId(dto.getHeaderImageId())
|
.headerImageId(dto.getHeaderImageId())
|
||||||
.values(dto.getValues() != null ? new HashMap<>(dto.getValues()) : new HashMap<>())
|
.values(dto.getValues() != null ? new HashMap<>(dto.getValues()) : new HashMap<>())
|
||||||
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
|
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
|
||||||
|
.keyValueValues(dto.getKeyValueValues() != null ? new HashMap<>(dto.getKeyValueValues()) : new HashMap<>())
|
||||||
.campaignId(dto.getCampaignId())
|
.campaignId(dto.getCampaignId())
|
||||||
.order(dto.getOrder())
|
.order(dto.getOrder())
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -6,14 +6,16 @@ import com.loremind.domain.shared.template.TemplateField;
|
|||||||
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mapper pour convertir entre {@link TemplateField} (domaine) et
|
* Mapper pour convertir entre {@link TemplateField} (domaine) et
|
||||||
* {@link TemplateFieldDTO} (wire).
|
* {@link TemplateFieldDTO} (wire).
|
||||||
* <p>
|
* <p>
|
||||||
* Tolerance : un type inconnu recu du client est interprete comme TEXT
|
* Tolerance : un type inconnu recu du client est interprete comme TEXT.
|
||||||
* (plus safe que de rejeter la requete et d'interrompre la sauvegarde).
|
|
||||||
* Un layout inconnu ou absent sur un champ IMAGE est interprete comme GALLERY.
|
* Un layout inconnu ou absent sur un champ IMAGE est interprete comme GALLERY.
|
||||||
* Le layout est force a null pour les champs TEXT.
|
* Layout/labels forces a null pour les types qui ne les utilisent pas.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class TemplateFieldMapper {
|
public class TemplateFieldMapper {
|
||||||
@@ -26,7 +28,11 @@ public class TemplateFieldMapper {
|
|||||||
ImageLayout layout = field.getLayout() != null ? field.getLayout() : ImageLayout.GALLERY;
|
ImageLayout layout = field.getLayout() != null ? field.getLayout() : ImageLayout.GALLERY;
|
||||||
layoutStr = layout.name();
|
layoutStr = layout.name();
|
||||||
}
|
}
|
||||||
return new TemplateFieldDTO(field.getName(), typeStr, layoutStr);
|
List<String> labels = null;
|
||||||
|
if (field.getType() == FieldType.KEY_VALUE_LIST && field.getLabels() != null) {
|
||||||
|
labels = new ArrayList<>(field.getLabels());
|
||||||
|
}
|
||||||
|
return new TemplateFieldDTO(field.getName(), typeStr, layoutStr, labels);
|
||||||
}
|
}
|
||||||
|
|
||||||
public TemplateField toDomain(TemplateFieldDTO dto) {
|
public TemplateField toDomain(TemplateFieldDTO dto) {
|
||||||
@@ -47,6 +53,10 @@ public class TemplateFieldMapper {
|
|||||||
layout = ImageLayout.GALLERY;
|
layout = ImageLayout.GALLERY;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return new TemplateField(dto.getName(), type, layout);
|
List<String> labels = null;
|
||||||
|
if (type == FieldType.KEY_VALUE_LIST && dto.getLabels() != null) {
|
||||||
|
labels = new ArrayList<>(dto.getLabels());
|
||||||
|
}
|
||||||
|
return new TemplateField(dto.getName(), type, layout, labels);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ public class NpcServiceTest {
|
|||||||
|
|
||||||
Npc result = npcService.createNpc(
|
Npc result = npcService.createNpc(
|
||||||
new NpcService.NpcData("Borin le forgeron", null, null,
|
new NpcService.NpcData("Borin le forgeron", null, null,
|
||||||
Map.of("Notes", "Borin"), null, "camp-1", 5));
|
Map.of("Notes", "Borin"), null, null, "camp-1", 5));
|
||||||
|
|
||||||
assertNotNull(result);
|
assertNotNull(result);
|
||||||
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
||||||
@@ -67,7 +67,7 @@ public class NpcServiceTest {
|
|||||||
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b));
|
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b));
|
||||||
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
||||||
|
|
||||||
npcService.createNpc(new NpcService.NpcData("Nouveau", null, null, null, null, "camp-1", null));
|
npcService.createNpc(new NpcService.NpcData("Nouveau", null, null, null, null, null, "camp-1", null));
|
||||||
|
|
||||||
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
||||||
verify(npcRepository).save(captor.capture());
|
verify(npcRepository).save(captor.capture());
|
||||||
@@ -79,7 +79,7 @@ public class NpcServiceTest {
|
|||||||
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
|
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
|
||||||
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
||||||
|
|
||||||
npcService.createNpc(new NpcService.NpcData("Premier", null, null, null, null, "camp-1", null));
|
npcService.createNpc(new NpcService.NpcData("Premier", null, null, null, null, null, "camp-1", null));
|
||||||
|
|
||||||
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
||||||
verify(npcRepository).save(captor.capture());
|
verify(npcRepository).save(captor.capture());
|
||||||
@@ -124,7 +124,7 @@ public class NpcServiceTest {
|
|||||||
|
|
||||||
Npc result = npcService.updateNpc("npc-1",
|
Npc result = npcService.updateNpc("npc-1",
|
||||||
new NpcService.NpcData("Borin renommé", null, null,
|
new NpcService.NpcData("Borin renommé", null, null,
|
||||||
Map.of("Notes", "v2"), null, "camp-1", 7));
|
Map.of("Notes", "v2"), null, null, "camp-1", 7));
|
||||||
|
|
||||||
assertEquals("Borin renommé", result.getName());
|
assertEquals("Borin renommé", result.getName());
|
||||||
assertEquals("v2", result.getValues().get("Notes"));
|
assertEquals("v2", result.getValues().get("Notes"));
|
||||||
@@ -138,7 +138,7 @@ public class NpcServiceTest {
|
|||||||
|
|
||||||
Npc result = npcService.updateNpc("npc-1",
|
Npc result = npcService.updateNpc("npc-1",
|
||||||
new NpcService.NpcData("Borin", null, null,
|
new NpcService.NpcData("Borin", null, null,
|
||||||
Map.of("Notes", "txt"), null, "camp-1", null));
|
Map.of("Notes", "txt"), null, null, "camp-1", null));
|
||||||
|
|
||||||
// testNpc avait order=1 → préservé
|
// testNpc avait order=1 → préservé
|
||||||
assertEquals(1, result.getOrder());
|
assertEquals(1, result.getOrder());
|
||||||
@@ -150,7 +150,7 @@ public class NpcServiceTest {
|
|||||||
|
|
||||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||||
() -> npcService.updateNpc("missing",
|
() -> npcService.updateNpc("missing",
|
||||||
new NpcService.NpcData("x", null, null, null, null, "camp-1", null)));
|
new NpcService.NpcData("x", null, null, null, null, null, "camp-1", null)));
|
||||||
assertTrue(ex.getMessage().contains("missing"));
|
assertTrue(ex.getMessage().contains("missing"));
|
||||||
verify(npcRepository, never()).save(any());
|
verify(npcRepository, never()).save(any());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,11 +102,18 @@ services:
|
|||||||
LICENSING_RELAY_BASE_URL: ${LICENSING_RELAY_BASE_URL:-https://loremind-auth.igmlcreation.fr}
|
LICENSING_RELAY_BASE_URL: ${LICENSING_RELAY_BASE_URL:-https://loremind-auth.igmlcreation.fr}
|
||||||
# Chemin du docker config.json partage avec Watchtower
|
# Chemin du docker config.json partage avec Watchtower
|
||||||
LICENSING_DOCKER_CONFIG_PATH: /shared/docker/config.json
|
LICENSING_DOCKER_CONFIG_PATH: /shared/docker/config.json
|
||||||
|
# Chemin du repertoire partage avec le switcher (commande + resultat).
|
||||||
|
# Doit matcher le volume `switcher-data` monte ci-dessous.
|
||||||
|
SWITCHER_DATA_PATH: /shared/switcher
|
||||||
volumes:
|
volumes:
|
||||||
# Volume partage avec Watchtower : Core ecrit les credentials registry
|
# Volume partage avec Watchtower : Core ecrit les credentials registry
|
||||||
# GHCR (recus du relais) ici, Watchtower les utilise pour pull les images
|
# GHCR (recus du relais) ici, Watchtower les utilise pour pull les images
|
||||||
# privees du canal beta. Pas de creds = no-op.
|
# privees du canal beta. Pas de creds = no-op.
|
||||||
- docker-config:/shared/docker
|
- docker-config:/shared/docker
|
||||||
|
# Volume partage avec le switcher : Core ecrit une commande de switch
|
||||||
|
# de canal ici (command.json), le switcher la traite et y depose son
|
||||||
|
# resultat (result.json). Cf. service `switcher` ci-dessous.
|
||||||
|
- switcher-data:/shared/switcher
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# Ollama embarque (option par defaut pour les utilisateurs sans Ollama installe).
|
# Ollama embarque (option par defaut pour les utilisateurs sans Ollama installe).
|
||||||
@@ -167,6 +174,44 @@ services:
|
|||||||
- "${WEB_PORT:-8081}:80"
|
- "${WEB_PORT:-8081}:80"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Sidecar de bascule de canal (stable <-> beta).
|
||||||
|
#
|
||||||
|
# Pourquoi : la bascule entre canaux change le PREFIXE d'image (loremind- vs
|
||||||
|
# loremind-beta-), donc Watchtower seul ne peut pas la faire — il met a jour
|
||||||
|
# des images, pas leur reference. Ce sidecar fait le `sed .env` + le
|
||||||
|
# `docker compose pull/up -d` quand le Core depose une commande JSON.
|
||||||
|
#
|
||||||
|
# Securite : pas de port expose. La commande arrive via volume partage
|
||||||
|
# (`switcher-data`) que SEUL le Core ecrit. Le switcher valide strictement
|
||||||
|
# le contenu (channel ∈ {stable, beta}, rien d'autre) — pas de RCE via
|
||||||
|
# compromission du Core.
|
||||||
|
#
|
||||||
|
# L'image switcher est volontairement HORS de IMAGE_NAMESPACE : elle reste
|
||||||
|
# `igmlcreation/loremind-switcher` sur les deux canaux. Sinon le switcher
|
||||||
|
# se tuerait lui-meme pendant le `docker compose up -d` (race condition).
|
||||||
|
switcher:
|
||||||
|
image: ghcr.io/igmlcreation/loremind-switcher:${SWITCHER_TAG:-latest}
|
||||||
|
container_name: loremind-switcher
|
||||||
|
# PAS de label watchtower : la maj du switcher se fait via le canal
|
||||||
|
# stable uniquement, et hors du flow d'auto-update.
|
||||||
|
volumes:
|
||||||
|
# Socket Docker du host : permet de lancer docker compose pull/up.
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
# Repertoire compose du host (docker-compose.yml + .env) — RW pour
|
||||||
|
# pouvoir sed la ligne IMAGE_NAMESPACE.
|
||||||
|
- ${COMPOSE_PROJECT_DIR:-./}:/compose
|
||||||
|
# Volume partage avec le Core pour la commande + le resultat.
|
||||||
|
- switcher-data:/data
|
||||||
|
environment:
|
||||||
|
# Repertoire interne ou trouver docker-compose.yml et .env. Bind au
|
||||||
|
# volume ci-dessus (COMPOSE_PROJECT_DIR = repertoire d'install du host).
|
||||||
|
COMPOSE_DIR: /compose
|
||||||
|
# Nom de projet docker compose : fixe ici pour que le switcher cible
|
||||||
|
# le MEME stack que celui qui tourne (sinon il creerait un duplicate).
|
||||||
|
# Doit matcher le `name:` (en V2.x) ou le nom du dossier du host.
|
||||||
|
COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-loremind}
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
# Mises a jour automatiques des images core/brain/web.
|
# Mises a jour automatiques des images core/brain/web.
|
||||||
# Active uniquement si COMPOSE_PROFILES=autoupdate (gere par l'installeur).
|
# Active uniquement si COMPOSE_PROFILES=autoupdate (gere par l'installeur).
|
||||||
# Postgres et MinIO sont volontairement exclus (donnees persistantes,
|
# Postgres et MinIO sont volontairement exclus (donnees persistantes,
|
||||||
@@ -214,3 +259,5 @@ volumes:
|
|||||||
# Volume partage Core <-> Watchtower : config.json Docker pour
|
# Volume partage Core <-> Watchtower : config.json Docker pour
|
||||||
# l'authentification au registry prive GHCR (canal beta Patreon).
|
# l'authentification au registry prive GHCR (canal beta Patreon).
|
||||||
docker-config:
|
docker-config:
|
||||||
|
# Volume partage Core <-> Switcher : commande de bascule de canal + resultat.
|
||||||
|
switcher-data:
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
Auteur : ietm64
|
Auteur : ietm64
|
||||||
Licence : AGPL-3.0
|
Licence : AGPL-3.0
|
||||||
Projet : LoreMindMJ - assistant pour Maitres de Jeu de JDR
|
Projet : LoreMindMJ - assistant pour Maitres de Jeu de JDR
|
||||||
Version : 0.8.1
|
Version : 0.8.3
|
||||||
|
|
||||||
.LINK
|
.LINK
|
||||||
https://github.com/IGMLcreation/LoreMind
|
https://github.com/IGMLcreation/LoreMind
|
||||||
|
|||||||
26
switcher/Dockerfile
Normal file
26
switcher/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# LoreMind channel switcher — sidecar minimal qui orchestre les bascules
|
||||||
|
# stable <-> beta. Tourne en permanence en attente d'une commande deposee
|
||||||
|
# dans le volume partage par le Core.
|
||||||
|
#
|
||||||
|
# Image volontairement legere (Alpine + docker-cli + bash). Pas de port
|
||||||
|
# expose, pas de processus reseau : tout passe par fichiers + socket Docker.
|
||||||
|
FROM alpine:3.20
|
||||||
|
|
||||||
|
# docker-cli : pour parler au socket Docker du host
|
||||||
|
# docker-cli-compose : pour `docker compose pull/up`
|
||||||
|
# bash : pour les scripts (sh ne suffit pas, on utilise des features bash)
|
||||||
|
# jq : parsing JSON de la commande
|
||||||
|
# coreutils : pour `date -u --iso-8601=seconds`
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
docker-cli \
|
||||||
|
docker-cli-compose \
|
||||||
|
bash \
|
||||||
|
jq \
|
||||||
|
coreutils
|
||||||
|
|
||||||
|
WORKDIR /switcher
|
||||||
|
COPY watch.sh switch.sh ./
|
||||||
|
RUN chmod +x watch.sh switch.sh
|
||||||
|
|
||||||
|
# Tourne en permanence en mode polling.
|
||||||
|
ENTRYPOINT ["/switcher/watch.sh"]
|
||||||
66
switcher/README.md
Normal file
66
switcher/README.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# LoreMind channel switcher
|
||||||
|
|
||||||
|
Sidecar qui bascule LoreMind entre les canaux **stable** et **beta** depuis l'UI,
|
||||||
|
sans manipulation manuelle du `.env` ni de docker-compose.
|
||||||
|
|
||||||
|
## Principe
|
||||||
|
|
||||||
|
Le switcher est un container minimal (Alpine + docker-cli + bash) qui :
|
||||||
|
|
||||||
|
1. Watch un fichier `command.json` dans un volume partagé avec le Core
|
||||||
|
2. Quand une commande arrive :
|
||||||
|
- Valide le canal cible (`stable` | `beta`)
|
||||||
|
- Sed la ligne `IMAGE_NAMESPACE` du `.env` du host
|
||||||
|
- Lance `docker compose pull` puis `docker compose up -d` sur core/brain/web
|
||||||
|
3. Écrit son résultat dans `result.json` (le Core remonte ça à l'UI via polling)
|
||||||
|
|
||||||
|
## Sécurité
|
||||||
|
|
||||||
|
Le switcher a accès au socket Docker et au répertoire compose du host (RW),
|
||||||
|
donc beaucoup de pouvoir. Pour éviter qu'une compromission du Core devienne
|
||||||
|
un RCE sur l'hôte :
|
||||||
|
|
||||||
|
- Le Core n'a **pas** accès au socket Docker — il dépose une commande dans un
|
||||||
|
fichier, point.
|
||||||
|
- Le switcher **valide strictement** le contenu : `channel` doit valoir exactement
|
||||||
|
`stable` ou `beta` (case statement, pas de regex laxiste).
|
||||||
|
- Aucun port n'est exposé. La communication se fait uniquement via volume
|
||||||
|
partagé.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┐ ┌──────────────────┐ ┌──────────────┐ ┌────────────┐
|
||||||
|
│ User clique │ │ Core │ │ switcher │ │ Docker │
|
||||||
|
│ "Passer beta"│─▶│ écrit command.json│─▶│ sed .env │─▶│ daemon │
|
||||||
|
│ dans UI │ │ dans volume │ │ docker compose│ │ (recreate) │
|
||||||
|
└──────────────┘ └──────────────────┘ └──────────────┘ └────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ result.json │ ◄── Core poll
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Upgrade pour les installs existantes
|
||||||
|
|
||||||
|
Le sidecar est arrivé dans LoreMind 0.9.0. Pour les installs antérieures qui
|
||||||
|
ne l'ont pas dans leur `docker-compose.yml`, l'utilisateur doit faire une
|
||||||
|
**dernière** manipulation :
|
||||||
|
|
||||||
|
1. Récupérer le nouveau `docker-compose.yml` du repo
|
||||||
|
2. Lancer `docker compose pull && docker compose up -d`
|
||||||
|
|
||||||
|
Après ça, tous les switchs futurs se font depuis l'UI sans intervention CLI.
|
||||||
|
|
||||||
|
## Pourquoi le switcher n'est PAS dans `IMAGE_NAMESPACE`
|
||||||
|
|
||||||
|
L'image du switcher est codée en dur (`ghcr.io/igmlcreation/loremind-switcher`)
|
||||||
|
plutôt que d'utiliser `${IMAGE_NAMESPACE}`. Raison : pendant un switch, le
|
||||||
|
switcher exécute `docker compose up -d`. Si son propre image faisait partie
|
||||||
|
de `IMAGE_NAMESPACE`, le compose voudrait le recréer en même temps que
|
||||||
|
core/brain/web — et il se tuerait au milieu de sa propre commande. Race
|
||||||
|
condition fatale.
|
||||||
|
|
||||||
|
Pour la même raison, le `docker compose up -d` dans `switch.sh` cible
|
||||||
|
explicitement `core brain web --no-deps` — jamais le switcher lui-même.
|
||||||
112
switcher/switch.sh
Normal file
112
switcher/switch.sh
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# switch.sh — execute le switch de canal pour LoreMind.
|
||||||
|
#
|
||||||
|
# Usage interne (appele par watch.sh) :
|
||||||
|
# ./switch.sh stable
|
||||||
|
# ./switch.sh beta
|
||||||
|
#
|
||||||
|
# Ce que ca fait, dans l'ordre :
|
||||||
|
# 1. Valide l'argument (stable|beta uniquement, rien d'autre — defense
|
||||||
|
# contre command injection si le Core etait compromis)
|
||||||
|
# 2. Sed la ligne IMAGE_NAMESPACE= du .env du host pour basculer le prefixe
|
||||||
|
# 3. docker compose pull (recupere les nouvelles images du canal cible)
|
||||||
|
# 4. docker compose up -d (recree core/brain/web avec les nouvelles images)
|
||||||
|
#
|
||||||
|
# Le switcher LUI-MEME n'est PAS dans IMAGE_NAMESPACE — il survit au switch
|
||||||
|
# sans interruption (cf. docker-compose.yml).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CHANNEL="${1:-}"
|
||||||
|
|
||||||
|
# --- Validation stricte -----------------------------------------------------
|
||||||
|
# Aucune autre valeur acceptee. Pas d'echappement, pas de slash, rien.
|
||||||
|
# C'est le filet de securite si le JSON depose dans /data/command.json
|
||||||
|
# contenait un payload exotique (Core compromis = on ne laisse PAS
|
||||||
|
# executer du code arbitraire sur l'hote).
|
||||||
|
case "${CHANNEL}" in
|
||||||
|
stable|beta) ;;
|
||||||
|
*)
|
||||||
|
echo "Channel invalide: '${CHANNEL}' (attendu: stable|beta)" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# --- Configuration ---------------------------------------------------------
|
||||||
|
# Repertoire monte depuis l'hote contenant docker-compose.yml + .env
|
||||||
|
COMPOSE_DIR="${COMPOSE_DIR:-/compose}"
|
||||||
|
ENV_FILE="${COMPOSE_DIR}/.env"
|
||||||
|
|
||||||
|
if [[ ! -f "${ENV_FILE}" ]]; then
|
||||||
|
echo "Fichier .env introuvable dans ${COMPOSE_DIR}" >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
if [[ ! -f "${COMPOSE_DIR}/docker-compose.yml" ]]; then
|
||||||
|
echo "docker-compose.yml introuvable dans ${COMPOSE_DIR}" >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Detection du nom de projet compose ------------------------------------
|
||||||
|
# Pour eviter que le switcher cree un projet PARALLELE (cas ou COMPOSE_PROJECT_NAME
|
||||||
|
# ne correspond pas au nom du projet sous lequel les containers tournent),
|
||||||
|
# on lit le label compose du container core en cours d'execution.
|
||||||
|
# Ce label est ecrit par docker compose au moment du `up -d` initial — c'est
|
||||||
|
# la source de verite.
|
||||||
|
PROJECT_NAME=$(docker inspect loremind-core \
|
||||||
|
--format '{{ index .Config.Labels "com.docker.compose.project" }}' \
|
||||||
|
2>/dev/null || echo "")
|
||||||
|
if [[ -z "${PROJECT_NAME}" ]]; then
|
||||||
|
# Fallback : env var ou defaut. Ne devrait pas arriver en prod
|
||||||
|
# (loremind-core tourne forcement quand l'UI declenche un switch).
|
||||||
|
PROJECT_NAME="${COMPOSE_PROJECT_NAME:-loremind}"
|
||||||
|
echo "Warning: nom de projet auto-detecte impossible, fallback sur '${PROJECT_NAME}'" >&2
|
||||||
|
fi
|
||||||
|
export COMPOSE_PROJECT_NAME="${PROJECT_NAME}"
|
||||||
|
echo "→ Projet compose cible: ${PROJECT_NAME}"
|
||||||
|
|
||||||
|
# --- Mapping canal -> namespace --------------------------------------------
|
||||||
|
# Le slash final est important : il est concatene avec le suffixe image
|
||||||
|
# (core/brain/web) dans le docker-compose.yml.
|
||||||
|
case "${CHANNEL}" in
|
||||||
|
stable) NAMESPACE="igmlcreation/loremind-" ;;
|
||||||
|
beta) NAMESPACE="igmlcreation/loremind-beta-" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# --- Etape 1 : sed le .env -------------------------------------------------
|
||||||
|
# On veut REMPLACER une ligne existante IMAGE_NAMESPACE=... ou AJOUTER
|
||||||
|
# si absente. Cas typique : .env utilisateur peut avoir cette ligne ou non.
|
||||||
|
#
|
||||||
|
# Sed -i avec un pattern qui matche la ligne entiere. Si pas de match,
|
||||||
|
# on append.
|
||||||
|
echo "→ Mise a jour de IMAGE_NAMESPACE dans .env (canal: ${CHANNEL})"
|
||||||
|
if grep -q '^IMAGE_NAMESPACE=' "${ENV_FILE}"; then
|
||||||
|
# Sur Alpine, sed -i sans backup. Le pattern d'echappement '/' dans
|
||||||
|
# le namespace impose un delimiter alternatif (|).
|
||||||
|
sed -i "s|^IMAGE_NAMESPACE=.*|IMAGE_NAMESPACE=${NAMESPACE}|" "${ENV_FILE}"
|
||||||
|
else
|
||||||
|
# Ligne absente → on l'ajoute en fin de fichier avec un commentaire.
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "# Ajoute automatiquement par le switcher de canal LoreMind."
|
||||||
|
echo "IMAGE_NAMESPACE=${NAMESPACE}"
|
||||||
|
} >> "${ENV_FILE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Etape 2 : docker compose pull -----------------------------------------
|
||||||
|
echo "→ Pull des nouvelles images (${NAMESPACE}*)"
|
||||||
|
# --no-deps inutile ici : pull n'a pas de notion de deps.
|
||||||
|
# --policy missing eviterait de re-puller si deja la, mais on VEUT puller
|
||||||
|
# pour avoir la derniere version disponible — c'est le but du switch.
|
||||||
|
cd "${COMPOSE_DIR}"
|
||||||
|
docker compose pull core brain web
|
||||||
|
|
||||||
|
# --- Etape 3 : recreate les containers avec les nouvelles images -----------
|
||||||
|
# On cible explicitement core/brain/web — pas le switcher (qui s'auto-tuerait
|
||||||
|
# au milieu de la commande), pas postgres/minio (pas de changement d'image).
|
||||||
|
# --no-deps : ne pas re-recreer postgres/minio comme effet de bord.
|
||||||
|
echo "→ Recreation des containers avec les nouvelles images"
|
||||||
|
docker compose up -d --no-deps core brain web
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Switch vers le canal ${CHANNEL} termine avec succes."
|
||||||
|
echo "Containers core/brain/web recrees avec ${NAMESPACE}*."
|
||||||
84
switcher/watch.sh
Normal file
84
switcher/watch.sh
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# watch.sh — boucle principale du switcher.
|
||||||
|
#
|
||||||
|
# Surveille /data/command.json (depose par le Core via l'API HTTP) et lance
|
||||||
|
# switch.sh quand une nouvelle commande arrive. L'ID de la commande sert
|
||||||
|
# d'idempotence : on ne traite pas deux fois la meme requete.
|
||||||
|
#
|
||||||
|
# Le resultat est ecrit dans /data/result.json pour que le Core puisse le
|
||||||
|
# remonter a l'UI via son endpoint de status.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DATA_DIR="/data"
|
||||||
|
COMMAND_FILE="${DATA_DIR}/command.json"
|
||||||
|
RESULT_FILE="${DATA_DIR}/result.json"
|
||||||
|
LAST_PROCESSED_FILE="${DATA_DIR}/.last-processed-id"
|
||||||
|
|
||||||
|
mkdir -p "${DATA_DIR}"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[$(date -u --iso-8601=seconds)] $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ecrit un resultat JSON dans result.json — atomique via tmp + mv.
|
||||||
|
write_result() {
|
||||||
|
local status="$1" # "in-progress" | "success" | "error"
|
||||||
|
local channel="$2" # "stable" | "beta" | ""
|
||||||
|
local message="$3"
|
||||||
|
local id="$4"
|
||||||
|
|
||||||
|
local tmp
|
||||||
|
tmp="$(mktemp -p "${DATA_DIR}" result.XXXXXX)"
|
||||||
|
cat > "${tmp}" <<EOF
|
||||||
|
{
|
||||||
|
"id": "${id}",
|
||||||
|
"status": "${status}",
|
||||||
|
"channel": "${channel}",
|
||||||
|
"message": $(printf '%s' "${message}" | jq -Rs .),
|
||||||
|
"completedAt": "$(date -u --iso-8601=seconds)"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
mv "${tmp}" "${RESULT_FILE}"
|
||||||
|
}
|
||||||
|
|
||||||
|
log "LoreMind channel switcher started — watching ${COMMAND_FILE}"
|
||||||
|
|
||||||
|
# Boucle de polling. Intervalle court (1s) — la charge est negligeable
|
||||||
|
# (un test de fichier) et l'utilisateur attend une reaction rapide.
|
||||||
|
while true; do
|
||||||
|
if [[ -f "${COMMAND_FILE}" ]]; then
|
||||||
|
# Parse la commande. Tolere les JSON malformes : on ignore et on attend.
|
||||||
|
if ! id=$(jq -er '.id' "${COMMAND_FILE}" 2>/dev/null); then
|
||||||
|
sleep 1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Idempotence : skip si on a deja traite cet ID.
|
||||||
|
last_id=""
|
||||||
|
[[ -f "${LAST_PROCESSED_FILE}" ]] && last_id=$(cat "${LAST_PROCESSED_FILE}")
|
||||||
|
if [[ "${id}" == "${last_id}" ]]; then
|
||||||
|
sleep 1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
channel=$(jq -er '.channel' "${COMMAND_FILE}" 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
log "New command received: id=${id} channel=${channel}"
|
||||||
|
write_result "in-progress" "${channel}" "Switch en cours..." "${id}"
|
||||||
|
|
||||||
|
# Lance le switch. On capture stdout+stderr et le code de sortie.
|
||||||
|
if output=$(/switcher/switch.sh "${channel}" 2>&1); then
|
||||||
|
log "Switch SUCCESS for id=${id} channel=${channel}"
|
||||||
|
write_result "success" "${channel}" "${output}" "${id}"
|
||||||
|
else
|
||||||
|
rc=$?
|
||||||
|
log "Switch FAILED for id=${id} channel=${channel} rc=${rc}"
|
||||||
|
write_result "error" "${channel}" "${output}" "${id}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Marque l'ID comme traite — empeche les replays.
|
||||||
|
echo "${id}" > "${LAST_PROCESSED_FILE}"
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
@@ -355,3 +355,55 @@ export async function getTemplateById(
|
|||||||
expect(res.ok(), `GET /api/templates/${templateId} -> ${res.status()}`).toBeTruthy();
|
expect(res.ok(), `GET /api/templates/${templateId} -> ${res.status()}`).toBeTruthy();
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────── GameSystem ───────────────
|
||||||
|
|
||||||
|
export interface SeededGameSystem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedGameSystem(
|
||||||
|
request: APIRequestContext,
|
||||||
|
opts: { name?: string; description?: string; author?: string; rulesMarkdown?: string } = {},
|
||||||
|
): Promise<SeededGameSystem> {
|
||||||
|
const name = opts.name ?? `E2E GameSystem ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||||
|
const res = await request.post('/api/game-systems', {
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
description: opts.description ?? null,
|
||||||
|
author: opts.author ?? null,
|
||||||
|
rulesMarkdown: opts.rulesMarkdown ?? null,
|
||||||
|
characterTemplate: [],
|
||||||
|
npcTemplate: [],
|
||||||
|
isPublic: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.ok(), `POST /api/game-systems -> ${res.status()}`).toBeTruthy();
|
||||||
|
const gs = await res.json();
|
||||||
|
return { id: gs.id, name };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteGameSystem(
|
||||||
|
request: APIRequestContext,
|
||||||
|
id: string,
|
||||||
|
): Promise<void> {
|
||||||
|
// Best-effort : ignore 404 si déjà supprimé par le test (ex: delete spec).
|
||||||
|
await request.delete(`/api/game-systems/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGameSystemById(
|
||||||
|
request: APIRequestContext,
|
||||||
|
id: string,
|
||||||
|
): Promise<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
author: string | null;
|
||||||
|
rulesMarkdown: string | null;
|
||||||
|
isPublic: boolean;
|
||||||
|
}> {
|
||||||
|
const res = await request.get(`/api/game-systems/${id}`);
|
||||||
|
expect(res.ok(), `GET /api/game-systems/${id} -> ${res.status()}`).toBeTruthy();
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,10 +24,12 @@ test.describe('Arc delete', () => {
|
|||||||
page,
|
page,
|
||||||
request,
|
request,
|
||||||
}) => {
|
}) => {
|
||||||
page.on('dialog', (dialog) => dialog.accept());
|
|
||||||
|
|
||||||
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
|
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
|
||||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
|
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
|
||||||
|
|
||||||
@@ -36,10 +38,12 @@ test.describe('Arc delete', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('keeps the arc when confirm is dismissed', async ({ page, request }) => {
|
test('keeps the arc when confirm is dismissed', async ({ page, request }) => {
|
||||||
page.on('dialog', (dialog) => dialog.dismiss());
|
|
||||||
|
|
||||||
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
|
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
|
||||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole('button', { name: /^Annuler$/i }).click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/arcs/${arc.id}/edit$`));
|
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/arcs/${arc.id}/edit$`));
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ test.describe('Arc edit', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
|
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
|
||||||
|
// Attend que le formulaire soit prerempli par le ngOnInit (HTTP async) avant
|
||||||
|
// de fill — sinon le patchValue du load arrive APRES nos fills et ecrase
|
||||||
|
// les valeurs, le test echoue alors a la verif persisted.name.
|
||||||
|
await expect(page.getByLabel(/Titre de l'arc/i)).toHaveValue(arc.name);
|
||||||
|
|
||||||
await page.getByLabel(/Titre de l'arc/i).fill(newName);
|
await page.getByLabel(/Titre de l'arc/i).fill(newName);
|
||||||
await page.getByLabel(/Synopsis de l'arc/i).fill(values.description);
|
await page.getByLabel(/Synopsis de l'arc/i).fill(values.description);
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ test.describe('Campaign delete', () => {
|
|||||||
page,
|
page,
|
||||||
request,
|
request,
|
||||||
}) => {
|
}) => {
|
||||||
page.on('dialog', (dialog) => dialog.accept());
|
|
||||||
|
|
||||||
await page.goto(`/campaigns/${campaign.id}`);
|
await page.goto(`/campaigns/${campaign.id}`);
|
||||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/campaigns$/);
|
await expect(page).toHaveURL(/\/campaigns$/);
|
||||||
|
|
||||||
@@ -28,10 +30,12 @@ test.describe('Campaign delete', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('keeps the campaign when confirm is dismissed', async ({ page, request }) => {
|
test('keeps the campaign when confirm is dismissed', async ({ page, request }) => {
|
||||||
page.on('dialog', (dialog) => dialog.dismiss());
|
|
||||||
|
|
||||||
await page.goto(`/campaigns/${campaign.id}`);
|
await page.goto(`/campaigns/${campaign.id}`);
|
||||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole('button', { name: /^Annuler$/i }).click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
|
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
|
||||||
|
|
||||||
|
|||||||
@@ -17,20 +17,22 @@ test.describe('NPC creation', () => {
|
|||||||
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('creates an NPC and redirects back to the campaign', async ({ page, request }) => {
|
test('creates an NPC and redirects to the NPC detail page', async ({ page, request }) => {
|
||||||
|
// Note : depuis la refonte 2026-04-30 les fiches PNJ utilisent des champs
|
||||||
|
// templates dynamiques pilotes par le GameSystem (plus de markdownContent
|
||||||
|
// libre). La campagne seedee n'a pas de GameSystem donc on ne fill que le
|
||||||
|
// nom — c'est suffisant pour valider la creation + la redirection.
|
||||||
const npcName = `Borin le forgeron ${Date.now()}`;
|
const npcName = `Borin le forgeron ${Date.now()}`;
|
||||||
const markdown = '# Borin\n\n**Faction :** Clan Feuillefer\n\nNain barbu au regard perçant.';
|
|
||||||
|
|
||||||
await page.goto(`/campaigns/${campaign.id}/npcs/create`);
|
await page.goto(`/campaigns/${campaign.id}/npcs/create`);
|
||||||
await expect(page.getByRole('heading', { name: /Nouveau PNJ/i })).toBeVisible();
|
await expect(page.getByRole('heading', { name: /Nouveau PNJ/i })).toBeVisible();
|
||||||
|
|
||||||
await page.getByLabel(/Nom du PNJ/i).fill(npcName);
|
await page.getByLabel(/Nom du PNJ/i).fill(npcName);
|
||||||
await page.getByLabel(/Fiche \(markdown\)/i).fill(markdown);
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /^Créer$/i }).click();
|
await page.getByRole('button', { name: /^Créer$/i }).click();
|
||||||
|
|
||||||
// Retour à la page campagne après création
|
// Redirection vers la fiche du PNJ après création
|
||||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
|
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/npcs/\\d+$`));
|
||||||
|
|
||||||
// Persistance vérifiée via API
|
// Persistance vérifiée via API
|
||||||
const npcs = await getNpcsByCampaign(request, campaign.id);
|
const npcs = await getNpcsByCampaign(request, campaign.id);
|
||||||
@@ -58,7 +60,7 @@ test.describe('NPC creation', () => {
|
|||||||
await page.getByLabel(/Nom du PNJ/i).fill(npcName);
|
await page.getByLabel(/Nom du PNJ/i).fill(npcName);
|
||||||
await page.getByRole('button', { name: /^Créer$/i }).click();
|
await page.getByRole('button', { name: /^Créer$/i }).click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
|
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/npcs/\\d+$`));
|
||||||
|
|
||||||
// Le nœud "PNJ" doit apparaître dans la sidebar avec le nouveau PNJ.
|
// Le nœud "PNJ" doit apparaître dans la sidebar avec le nouveau PNJ.
|
||||||
// On clique sur le nœud PNJ pour le déplier au cas où il serait fermé,
|
// On clique sur le nœud PNJ pour le déplier au cas où il serait fermé,
|
||||||
|
|||||||
@@ -14,19 +14,19 @@ test.describe('NPC edit', () => {
|
|||||||
|
|
||||||
test.beforeEach(async ({ request }) => {
|
test.beforeEach(async ({ request }) => {
|
||||||
campaign = await seedCampaign(request);
|
campaign = await seedCampaign(request);
|
||||||
npc = await seedNpc(request, {
|
npc = await seedNpc(request, { campaignId: campaign.id });
|
||||||
campaignId: campaign.id,
|
|
||||||
markdownContent: '# Initial\n\nFiche de départ.',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterEach(async ({ request }) => {
|
test.afterEach(async ({ request }) => {
|
||||||
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('edits name + markdown content and persists via API', async ({ page, request }) => {
|
test('edits name and persists via API', async ({ page, request }) => {
|
||||||
|
// Note : depuis la refonte 2026-04-30 les fiches PNJ utilisent des champs
|
||||||
|
// templates dynamiques pilotes par le GameSystem, plus le markdownContent
|
||||||
|
// libre. La campagne seedee n'a pas de GameSystem donc pas de champs
|
||||||
|
// dynamiques a tester ici — on se contente du nom (champ universel).
|
||||||
const newName = `${npc.name} (renommé)`;
|
const newName = `${npc.name} (renommé)`;
|
||||||
const newMarkdown = '# Borin réécrit\n\n**Statut :** Disparu\n\nDes traces dans la neige...';
|
|
||||||
|
|
||||||
await page.goto(`/campaigns/${campaign.id}/npcs/${npc.id}/edit`);
|
await page.goto(`/campaigns/${campaign.id}/npcs/${npc.id}/edit`);
|
||||||
|
|
||||||
@@ -34,7 +34,6 @@ test.describe('NPC edit', () => {
|
|||||||
await expect(page.getByLabel(/Nom du PNJ/i)).toHaveValue(npc.name);
|
await expect(page.getByLabel(/Nom du PNJ/i)).toHaveValue(npc.name);
|
||||||
|
|
||||||
await page.getByLabel(/Nom du PNJ/i).fill(newName);
|
await page.getByLabel(/Nom du PNJ/i).fill(newName);
|
||||||
await page.getByLabel(/Fiche \(markdown\)/i).fill(newMarkdown);
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /^Enregistrer$/i }).click();
|
await page.getByRole('button', { name: /^Enregistrer$/i }).click();
|
||||||
|
|
||||||
@@ -43,7 +42,6 @@ test.describe('NPC edit', () => {
|
|||||||
|
|
||||||
const persisted = await getNpcById(request, npc.id);
|
const persisted = await getNpcById(request, npc.id);
|
||||||
expect(persisted.name).toBe(newName);
|
expect(persisted.name).toBe(newName);
|
||||||
expect(persisted.markdownContent).toBe(newMarkdown);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('save button is disabled when name is cleared', async ({ page }) => {
|
test('save button is disabled when name is cleared', async ({ page }) => {
|
||||||
|
|||||||
74
web/e2e/tests/game-system/game-system-create.spec.ts
Normal file
74
web/e2e/tests/game-system/game-system-create.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { deleteGameSystem } from '../../fixtures/api';
|
||||||
|
|
||||||
|
test.describe('GameSystem creation', () => {
|
||||||
|
// Les game systems crees par les tests sont nettoyes via cet array — chaque
|
||||||
|
// test pousse les IDs qu'il a crees pour qu'on les supprime en afterEach.
|
||||||
|
const createdIds: string[] = [];
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
while (createdIds.length) {
|
||||||
|
const id = createdIds.pop()!;
|
||||||
|
await deleteGameSystem(request, id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates a game system and redirects to the list', async ({ page, request }) => {
|
||||||
|
const gsName = `Système E2E ${Date.now()}`;
|
||||||
|
const description = 'Système créé par les tests automatisés.';
|
||||||
|
const author = 'Playwright';
|
||||||
|
|
||||||
|
await page.goto('/game-systems');
|
||||||
|
await expect(page.getByRole('heading', { name: /Systèmes de JDR/i })).toBeVisible();
|
||||||
|
|
||||||
|
// Carte "Nouveau système" → ouvre l'editeur en mode creation.
|
||||||
|
await page.locator('.gs-card.card-new').click();
|
||||||
|
await expect(page).toHaveURL(/\/game-systems\/create$/);
|
||||||
|
await expect(page.getByRole('heading', { name: /Nouveau système de JDR/i })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel(/^Nom/i).fill(gsName);
|
||||||
|
await page.getByLabel(/Description courte/i).fill(description);
|
||||||
|
await page.getByLabel(/Auteur/i).fill(author);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^Créer$/i }).click();
|
||||||
|
|
||||||
|
// Redirection vers la liste apres creation.
|
||||||
|
await expect(page).toHaveURL(/\/game-systems$/);
|
||||||
|
// Et la carte du nouveau systeme est visible dans la grille.
|
||||||
|
await expect(page.locator('.gs-card', { hasText: gsName })).toBeVisible();
|
||||||
|
|
||||||
|
// Verification API : le systeme est bien persistant.
|
||||||
|
const all = await request.get('/api/game-systems').then((r) => r.json());
|
||||||
|
const created = all.find((gs: { id: string; name: string }) => gs.name === gsName);
|
||||||
|
expect(created).toBeDefined();
|
||||||
|
expect(created.author).toBe(author);
|
||||||
|
createdIds.push(created.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('submit button is disabled when name is empty', async ({ page }) => {
|
||||||
|
await page.goto('/game-systems/create');
|
||||||
|
|
||||||
|
const submit = page.getByRole('button', { name: /^Créer$/i });
|
||||||
|
await expect(submit).toBeDisabled();
|
||||||
|
|
||||||
|
await page.getByLabel(/^Nom/i).fill('Quelque chose');
|
||||||
|
await expect(submit).toBeEnabled();
|
||||||
|
|
||||||
|
await page.getByLabel(/^Nom/i).fill(' ');
|
||||||
|
await expect(submit).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancel returns to the list without creating', async ({ page, request }) => {
|
||||||
|
const abandoned = `Système abandonné ${Date.now()}`;
|
||||||
|
|
||||||
|
await page.goto('/game-systems/create');
|
||||||
|
await page.getByLabel(/^Nom/i).fill(abandoned);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^Annuler$/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/game-systems$/);
|
||||||
|
|
||||||
|
// Rien n'a ete cree cote API.
|
||||||
|
const all = await request.get('/api/game-systems').then((r) => r.json());
|
||||||
|
expect(all.find((gs: { name: string }) => gs.name === abandoned)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
61
web/e2e/tests/game-system/game-system-delete.spec.ts
Normal file
61
web/e2e/tests/game-system/game-system-delete.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
seedGameSystem,
|
||||||
|
deleteGameSystem,
|
||||||
|
type SeededGameSystem,
|
||||||
|
} from '../../fixtures/api';
|
||||||
|
|
||||||
|
test.describe('GameSystem delete', () => {
|
||||||
|
let gs: SeededGameSystem;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
gs = await seedGameSystem(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
// Best-effort cleanup — ne fait rien si deja supprime par le test.
|
||||||
|
if (gs?.id) await deleteGameSystem(request, gs.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deletes a game system after confirming and removes it from the list', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/game-systems');
|
||||||
|
|
||||||
|
const card = page.locator('.gs-card', { hasText: gs.name });
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
|
||||||
|
// Bouton corbeille dans le coin de la carte du systeme seede.
|
||||||
|
await card.locator('.icon-btn').click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await expect(dialog).toContainText(gs.name);
|
||||||
|
|
||||||
|
await dialog.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||||
|
|
||||||
|
// La carte disparait apres reload de la liste.
|
||||||
|
await expect(page.locator('.gs-card', { hasText: gs.name })).toHaveCount(0);
|
||||||
|
|
||||||
|
const res = await request.get(`/api/game-systems/${gs.id}`);
|
||||||
|
expect(res.status()).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the game system when cancel is clicked', async ({ page, request }) => {
|
||||||
|
await page.goto('/game-systems');
|
||||||
|
|
||||||
|
const card = page.locator('.gs-card', { hasText: gs.name });
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
await card.locator('.icon-btn').click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole('button', { name: /^Annuler$/i }).click();
|
||||||
|
|
||||||
|
// La carte est toujours la, le systeme est toujours en base.
|
||||||
|
await expect(page.locator('.gs-card', { hasText: gs.name })).toBeVisible();
|
||||||
|
const res = await request.get(`/api/game-systems/${gs.id}`);
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
68
web/e2e/tests/game-system/game-system-edit.spec.ts
Normal file
68
web/e2e/tests/game-system/game-system-edit.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
seedGameSystem,
|
||||||
|
deleteGameSystem,
|
||||||
|
getGameSystemById,
|
||||||
|
type SeededGameSystem,
|
||||||
|
} from '../../fixtures/api';
|
||||||
|
|
||||||
|
test.describe('GameSystem edit', () => {
|
||||||
|
let gs: SeededGameSystem;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
gs = await seedGameSystem(request, {
|
||||||
|
description: 'Description initiale.',
|
||||||
|
author: 'Auteur initial',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
if (gs?.id) await deleteGameSystem(request, gs.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('form is prefilled with the game system data', async ({ page }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: /Éditer le système/i })).toBeVisible();
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
await expect(page.getByLabel(/Description courte/i)).toHaveValue('Description initiale.');
|
||||||
|
await expect(page.getByLabel(/Auteur/i)).toHaveValue('Auteur initial');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('edits name and description and persists them to API', async ({ page, request }) => {
|
||||||
|
const newName = `${gs.name} renamed`;
|
||||||
|
const newDescription = 'Description mise à jour par le test.';
|
||||||
|
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
|
||||||
|
// Attente que le formulaire soit prerempli avant de fill — sinon le load
|
||||||
|
// async ecrase les valeurs filled (cf. bug arc-edit corrige).
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
await page.getByLabel(/^Nom/i).fill(newName);
|
||||||
|
await page.getByLabel(/Description courte/i).fill(newDescription);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^Enregistrer$/i }).click();
|
||||||
|
|
||||||
|
// Retour a la liste apres save.
|
||||||
|
await expect(page).toHaveURL(/\/game-systems$/);
|
||||||
|
|
||||||
|
const persisted = await getGameSystemById(request, gs.id);
|
||||||
|
expect(persisted.name).toBe(newName);
|
||||||
|
expect(persisted.description).toBe(newDescription);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('save button is disabled when name is cleared', async ({ page }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
const nameField = page.getByLabel(/^Nom/i);
|
||||||
|
const saveBtn = page.getByRole('button', { name: /^Enregistrer$/i });
|
||||||
|
|
||||||
|
await expect(saveBtn).toBeEnabled();
|
||||||
|
await nameField.fill('');
|
||||||
|
await expect(saveBtn).toBeDisabled();
|
||||||
|
await nameField.fill('OK');
|
||||||
|
await expect(saveBtn).toBeEnabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
111
web/e2e/tests/game-system/game-system-sections.spec.ts
Normal file
111
web/e2e/tests/game-system/game-system-sections.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
seedGameSystem,
|
||||||
|
deleteGameSystem,
|
||||||
|
type SeededGameSystem,
|
||||||
|
} from '../../fixtures/api';
|
||||||
|
|
||||||
|
test.describe('GameSystem rule sections editor', () => {
|
||||||
|
let gs: SeededGameSystem;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
// On part d'un GameSystem vide (pas de regles seedees) — chaque test gere
|
||||||
|
// ses propres ajouts pour eviter les couplages.
|
||||||
|
gs = await seedGameSystem(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
if (gs?.id) await deleteGameSystem(request, gs.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds a suggested section, fills it, and persists it', async ({ page, request }) => {
|
||||||
|
const sectionContent = 'Initiative à d20, action+bonus+mouvement, dégâts par dés.';
|
||||||
|
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
// Attendre le chargement du form (nom prerempli).
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
// Empty state visible tant qu'aucune section n'est ajoutee.
|
||||||
|
await expect(page.locator('.section-list .empty-hint')).toBeVisible();
|
||||||
|
|
||||||
|
// Ajout via la chip suggeree "Combat".
|
||||||
|
await page.locator('.add-row .chip', { hasText: 'Combat' }).click();
|
||||||
|
|
||||||
|
// Une section-card est apparue avec titre "Combat" prerempli + textarea visible.
|
||||||
|
const card = page.locator('.section-card').first();
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
await expect(card.locator('.section-title-input')).toHaveValue('Combat');
|
||||||
|
await card.locator('.section-content').fill(sectionContent);
|
||||||
|
|
||||||
|
// Save + retour a la liste.
|
||||||
|
await page.getByRole('button', { name: /^Enregistrer$/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/game-systems$/);
|
||||||
|
|
||||||
|
// Verification cote API : le markdown contient bien la section + son contenu.
|
||||||
|
const persisted = await request.get(`/api/game-systems/${gs.id}`).then((r) => r.json());
|
||||||
|
expect(persisted.rulesMarkdown).toContain('## Combat');
|
||||||
|
expect(persisted.rulesMarkdown).toContain(sectionContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disables a suggested chip after it has been used', async ({ page }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
const combatChip = page.locator('.add-row .chip', { hasText: 'Combat' });
|
||||||
|
await expect(combatChip).toBeEnabled();
|
||||||
|
|
||||||
|
await combatChip.click();
|
||||||
|
|
||||||
|
// Apres ajout, la chip "Combat" est desactivee (suggestion deja utilisee).
|
||||||
|
await expect(combatChip).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds a custom blank section via "Autre…" and lets the user name it', async ({ page }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
await page.locator('.add-row .chip-custom', { hasText: /Autre/i }).click();
|
||||||
|
|
||||||
|
// Section vierge ajoutee : titre vide, prete a remplir.
|
||||||
|
const card = page.locator('.section-card').first();
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
const titleInput = card.locator('.section-title-input');
|
||||||
|
await expect(titleInput).toHaveValue('');
|
||||||
|
await titleInput.fill('Sorts');
|
||||||
|
await expect(titleInput).toHaveValue('Sorts');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removes a section', async ({ page }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
await page.locator('.add-row .chip', { hasText: 'Combat' }).click();
|
||||||
|
await page.locator('.add-row .chip', { hasText: 'Classes' }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('.section-card')).toHaveCount(2);
|
||||||
|
|
||||||
|
// Supprime la premiere section (Combat).
|
||||||
|
await page.locator('.section-card').first().locator('.btn-remove').click();
|
||||||
|
await expect(page.locator('.section-card')).toHaveCount(1);
|
||||||
|
await expect(page.locator('.section-card').first().locator('.section-title-input')).toHaveValue('Classes');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collapses and expands a section', async ({ page }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
await page.locator('.add-row .chip', { hasText: 'Combat' }).click();
|
||||||
|
const card = page.locator('.section-card').first();
|
||||||
|
|
||||||
|
// Par defaut deployee : textarea visible.
|
||||||
|
await expect(card.locator('.section-content')).toBeVisible();
|
||||||
|
|
||||||
|
// Clic sur le bouton collapse → textarea masquee.
|
||||||
|
await card.locator('.btn-collapse').click();
|
||||||
|
await expect(card.locator('.section-content')).toHaveCount(0);
|
||||||
|
|
||||||
|
// Re-clic → re-deployee.
|
||||||
|
await card.locator('.btn-collapse').click();
|
||||||
|
await expect(card.locator('.section-content')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
151
web/e2e/tests/game-system/game-system-templates.spec.ts
Normal file
151
web/e2e/tests/game-system/game-system-templates.spec.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { test, expect, Page } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
seedGameSystem,
|
||||||
|
deleteGameSystem,
|
||||||
|
type SeededGameSystem,
|
||||||
|
} from '../../fixtures/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests du composant <app-template-fields-editor> dans le contexte GameSystem.
|
||||||
|
*
|
||||||
|
* Le composant est instancie DEUX fois sur la page d'edition d'un GameSystem
|
||||||
|
* (une fois pour PJ "characterTemplate", une fois pour PNJ "npcTemplate"), donc
|
||||||
|
* les selecteurs doivent etre scopes a l'instance ciblee. On utilise un helper
|
||||||
|
* `tfe(label)` qui renvoie le locator de l'editeur correspondant au titre.
|
||||||
|
*/
|
||||||
|
test.describe('GameSystem template fields editor (PJ / PNJ)', () => {
|
||||||
|
let gs: SeededGameSystem;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
gs = await seedGameSystem(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
if (gs?.id) await deleteGameSystem(request, gs.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Helper : retourne le locator de l'editeur de templates par son label. */
|
||||||
|
const tfe = (page: Page, label: 'PJ' | 'PNJ') =>
|
||||||
|
page.locator('.tfe').filter({ hasText: `Champs de la fiche ${label}` });
|
||||||
|
|
||||||
|
test('adds a suggested field to the PJ template and persists it', async ({ page, request }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
const pjEditor = tfe(page, 'PJ');
|
||||||
|
await expect(pjEditor).toBeVisible();
|
||||||
|
|
||||||
|
// Ajout de "Histoire" via la chip suggeree.
|
||||||
|
await pjEditor.locator('.tfe-add .chip', { hasText: 'Histoire' }).click();
|
||||||
|
|
||||||
|
// Une row apparait avec le nom prerempli.
|
||||||
|
const row = pjEditor.locator('.tfe-item').first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await expect(row.locator('.tfe-name')).toHaveValue('Histoire');
|
||||||
|
|
||||||
|
// Save → retour a la liste.
|
||||||
|
await page.getByRole('button', { name: /^Enregistrer$/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/game-systems$/);
|
||||||
|
|
||||||
|
// Verification API : le champ est bien dans characterTemplate.
|
||||||
|
const persisted = await request.get(`/api/game-systems/${gs.id}`).then((r) => r.json());
|
||||||
|
expect(persisted.characterTemplate).toEqual(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ name: 'Histoire' })]),
|
||||||
|
);
|
||||||
|
// npcTemplate non touche (toujours vide).
|
||||||
|
expect(persisted.npcTemplate ?? []).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds a custom NUMBER field via "Nombre" chip', async ({ page }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
const pjEditor = tfe(page, 'PJ');
|
||||||
|
await pjEditor.locator('.tfe-add .chip-custom', { hasText: 'Nombre' }).click();
|
||||||
|
|
||||||
|
const row = pjEditor.locator('.tfe-item').first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
// Champ vide, nom a remplir, type "NUMBER" pre-selectionne dans le select.
|
||||||
|
await expect(row.locator('.tfe-name')).toHaveValue('');
|
||||||
|
await expect(row.locator('.tfe-type')).toHaveValue('NUMBER');
|
||||||
|
|
||||||
|
await row.locator('.tfe-name').fill('Points de vie');
|
||||||
|
await expect(row.locator('.tfe-name')).toHaveValue('Points de vie');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PJ and PNJ editors are independent (adding to one does not affect the other)', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
await tfe(page, 'PJ').locator('.tfe-add .chip', { hasText: 'Histoire' }).click();
|
||||||
|
await tfe(page, 'PNJ').locator('.tfe-add .chip', { hasText: 'Motivation' }).click();
|
||||||
|
|
||||||
|
await expect(tfe(page, 'PJ').locator('.tfe-item')).toHaveCount(1);
|
||||||
|
await expect(tfe(page, 'PNJ').locator('.tfe-item')).toHaveCount(1);
|
||||||
|
await expect(tfe(page, 'PJ').locator('.tfe-item').first().locator('.tfe-name')).toHaveValue('Histoire');
|
||||||
|
await expect(tfe(page, 'PNJ').locator('.tfe-item').first().locator('.tfe-name')).toHaveValue('Motivation');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^Enregistrer$/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/game-systems$/);
|
||||||
|
|
||||||
|
const persisted = await request.get(`/api/game-systems/${gs.id}`).then((r) => r.json());
|
||||||
|
expect(persisted.characterTemplate).toEqual(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ name: 'Histoire' })]),
|
||||||
|
);
|
||||||
|
expect(persisted.npcTemplate).toEqual(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ name: 'Motivation' })]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removes a field from the template', async ({ page }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
const pjEditor = tfe(page, 'PJ');
|
||||||
|
await pjEditor.locator('.tfe-add .chip', { hasText: 'Histoire' }).click();
|
||||||
|
await pjEditor.locator('.tfe-add .chip', { hasText: 'Apparence' }).click();
|
||||||
|
|
||||||
|
await expect(pjEditor.locator('.tfe-item')).toHaveCount(2);
|
||||||
|
|
||||||
|
// Supprime le premier champ (Histoire) via son btn-remove.
|
||||||
|
await pjEditor.locator('.tfe-item').first().locator('.btn-remove').click();
|
||||||
|
await expect(pjEditor.locator('.tfe-item')).toHaveCount(1);
|
||||||
|
await expect(pjEditor.locator('.tfe-item').first().locator('.tfe-name')).toHaveValue('Apparence');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reorders fields with the up arrow button', async ({ page }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
const pjEditor = tfe(page, 'PJ');
|
||||||
|
await pjEditor.locator('.tfe-add .chip', { hasText: 'Histoire' }).click();
|
||||||
|
await pjEditor.locator('.tfe-add .chip', { hasText: 'Apparence' }).click();
|
||||||
|
|
||||||
|
// Ordre initial : Histoire, Apparence.
|
||||||
|
let rows = pjEditor.locator('.tfe-item');
|
||||||
|
await expect(rows.nth(0).locator('.tfe-name')).toHaveValue('Histoire');
|
||||||
|
await expect(rows.nth(1).locator('.tfe-name')).toHaveValue('Apparence');
|
||||||
|
|
||||||
|
// Monte Apparence d'un cran.
|
||||||
|
await rows.nth(1).locator('.btn-arrow').first().click();
|
||||||
|
|
||||||
|
rows = pjEditor.locator('.tfe-item');
|
||||||
|
await expect(rows.nth(0).locator('.tfe-name')).toHaveValue('Apparence');
|
||||||
|
await expect(rows.nth(1).locator('.tfe-name')).toHaveValue('Histoire');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disables a suggested chip after the field has been added', async ({ page }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
const pjEditor = tfe(page, 'PJ');
|
||||||
|
const histoireChip = pjEditor.locator('.tfe-add .chip', { hasText: 'Histoire' });
|
||||||
|
|
||||||
|
await expect(histoireChip).toBeEnabled();
|
||||||
|
await histoireChip.click();
|
||||||
|
await expect(histoireChip).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,32 +17,31 @@ test.describe('Lore delete', () => {
|
|||||||
page,
|
page,
|
||||||
request,
|
request,
|
||||||
}) => {
|
}) => {
|
||||||
let confirmMessage = '';
|
|
||||||
page.on('dialog', async (dialog) => {
|
|
||||||
confirmMessage = dialog.message();
|
|
||||||
await dialog.accept();
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto(`/lore/${seeded.id}`);
|
await page.goto(`/lore/${seeded.id}`);
|
||||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
|
||||||
|
|
||||||
// Attente du dialog et du retour sur la liste des lores.
|
const dialog = page.getByRole('dialog');
|
||||||
await expect(page).toHaveURL(/\/lore$/);
|
await expect(dialog).toBeVisible();
|
||||||
expect(confirmMessage).toContain(seeded.name);
|
await expect(dialog).toContainText(seeded.name);
|
||||||
// Lore contient un dossier seedé : le récapitulatif doit l'indiquer.
|
// Lore contient un dossier seedé : le récapitulatif doit l'indiquer.
|
||||||
expect(confirmMessage).toMatch(/1 dossier/i);
|
await expect(dialog).toContainText(/1 dossier/i);
|
||||||
|
|
||||||
|
await dialog.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||||
|
|
||||||
|
// Attente du retour sur la liste des lores.
|
||||||
|
await expect(page).toHaveURL(/\/lore$/);
|
||||||
|
|
||||||
const res = await request.get(`/api/lores/${seeded.id}`);
|
const res = await request.get(`/api/lores/${seeded.id}`);
|
||||||
expect(res.status()).toBe(404);
|
expect(res.status()).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('keeps the lore when the confirm is dismissed', async ({ page, request }) => {
|
test('keeps the lore when the confirm is dismissed', async ({ page, request }) => {
|
||||||
page.on('dialog', async (dialog) => {
|
|
||||||
await dialog.dismiss();
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto(`/lore/${seeded.id}`);
|
await page.goto(`/lore/${seeded.id}`);
|
||||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole('button', { name: /^Annuler$/i }).click();
|
||||||
|
|
||||||
// On reste sur le détail, le titre du lore est toujours visible.
|
// On reste sur le détail, le titre du lore est toujours visible.
|
||||||
await expect(page.locator('.detail-header h1')).toHaveText(seeded.name);
|
await expect(page.locator('.detail-header h1')).toHaveText(seeded.name);
|
||||||
|
|||||||
@@ -32,10 +32,12 @@ test.describe('Page delete', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('deletes the page after accepting confirm', async ({ page, request }) => {
|
test('deletes the page after accepting confirm', async ({ page, request }) => {
|
||||||
page.on('dialog', (dialog) => dialog.accept());
|
|
||||||
|
|
||||||
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
|
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
|
||||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||||
|
|
||||||
// Le composant redirige vers la racine du Lore après suppression.
|
// Le composant redirige vers la racine du Lore après suppression.
|
||||||
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
|
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
|
||||||
@@ -45,10 +47,12 @@ test.describe('Page delete', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('keeps the page when confirm is dismissed', async ({ page, request }) => {
|
test('keeps the page when confirm is dismissed', async ({ page, request }) => {
|
||||||
page.on('dialog', (dialog) => dialog.dismiss());
|
|
||||||
|
|
||||||
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
|
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
|
||||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole('button', { name: /^Annuler$/i }).click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/pages/${pageEntity.id}/edit$`));
|
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/pages/${pageEntity.id}/edit$`));
|
||||||
|
|
||||||
|
|||||||
@@ -25,11 +25,13 @@ test.describe('Template delete', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('deletes the template after accepting confirm', async ({ page, request }) => {
|
test('deletes the template after accepting confirm', async ({ page, request }) => {
|
||||||
page.on('dialog', (dialog) => dialog.accept());
|
|
||||||
|
|
||||||
await page.goto(`/lore/${seeded.id}/templates/${template.id}`);
|
await page.goto(`/lore/${seeded.id}/templates/${template.id}`);
|
||||||
await page.locator('.page-header .btn-danger').click();
|
await page.locator('.page-header .btn-danger').click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
|
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
|
||||||
|
|
||||||
const templates = await getTemplatesForLore(request, seeded.id);
|
const templates = await getTemplatesForLore(request, seeded.id);
|
||||||
@@ -37,11 +39,13 @@ test.describe('Template delete', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('keeps the template when confirm is dismissed', async ({ page, request }) => {
|
test('keeps the template when confirm is dismissed', async ({ page, request }) => {
|
||||||
page.on('dialog', (dialog) => dialog.dismiss());
|
|
||||||
|
|
||||||
await page.goto(`/lore/${seeded.id}/templates/${template.id}`);
|
await page.goto(`/lore/${seeded.id}/templates/${template.id}`);
|
||||||
await page.locator('.page-header .btn-danger').click();
|
await page.locator('.page-header .btn-danger').click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole('button', { name: /^Annuler$/i }).click();
|
||||||
|
|
||||||
// On reste sur l'écran d'édition (l'URL ne change pas).
|
// On reste sur l'écran d'édition (l'URL ne change pas).
|
||||||
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/templates/${template.id}$`));
|
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/templates/${template.id}$`));
|
||||||
|
|
||||||
|
|||||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.8.1",
|
"version": "0.8.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.8.1",
|
"version": "0.8.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^17.0.0",
|
"@angular/animations": "^17.0.0",
|
||||||
"@angular/common": "^17.0.0",
|
"@angular/common": "^17.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.8.1",
|
"version": "0.8.5",
|
||||||
"description": "LoreMind Frontend - Angular",
|
"description": "LoreMind Frontend - Angular",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
const baseURL = process.env['E2E_BASE_URL'] || 'http://localhost:8081';
|
// Par defaut on cible le serveur de dev Angular (ng serve) sur :4200 pour les
|
||||||
|
// runs locaux — c'est ce qu'on veut quand on bosse en TDD/dev sur le front.
|
||||||
|
// La CI (.gitea/workflows/e2e.yml) override avec `E2E_BASE_URL=http://web`
|
||||||
|
// pour cibler l'instance Docker dans le reseau du runner. Pour tester
|
||||||
|
// localement contre le container docker-compose, lancer :
|
||||||
|
// E2E_BASE_URL=http://localhost:8081 npm run e2e
|
||||||
|
const baseURL = process.env['E2E_BASE_URL'] || 'http://localhost:4200';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './e2e/tests',
|
testDir: './e2e/tests',
|
||||||
|
|||||||
@@ -18,3 +18,4 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<app-global-search></app-global-search>
|
<app-global-search></app-global-search>
|
||||||
|
<app-confirm-dialog-host></app-confirm-dialog-host>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { SidebarComponent } from './sidebar/sidebar.component';
|
|||||||
import { SecondarySidebarComponent } from './shared/secondary-sidebar/secondary-sidebar.component';
|
import { SecondarySidebarComponent } from './shared/secondary-sidebar/secondary-sidebar.component';
|
||||||
import { GlobalSearchComponent } from './shared/global-search/global-search.component';
|
import { GlobalSearchComponent } from './shared/global-search/global-search.component';
|
||||||
import { UpdateBannerComponent } from './shared/update-banner/update-banner.component';
|
import { UpdateBannerComponent } from './shared/update-banner/update-banner.component';
|
||||||
|
import { ConfirmDialogHostComponent } from './shared/confirm-dialog/confirm-dialog-host.component';
|
||||||
import { LayoutService } from './services/layout.service';
|
import { LayoutService } from './services/layout.service';
|
||||||
import { GlobalSearchService } from './services/global-search.service';
|
import { GlobalSearchService } from './services/global-search.service';
|
||||||
import { VersionCheckerService } from './services/version-checker.service';
|
import { VersionCheckerService } from './services/version-checker.service';
|
||||||
@@ -18,6 +19,7 @@ import { VersionCheckerService } from './services/version-checker.service';
|
|||||||
SecondarySidebarComponent,
|
SecondarySidebarComponent,
|
||||||
GlobalSearchComponent,
|
GlobalSearchComponent,
|
||||||
UpdateBannerComponent,
|
UpdateBannerComponent,
|
||||||
|
ConfirmDialogHostComponent,
|
||||||
AsyncPipe,
|
AsyncPipe,
|
||||||
NgIf,
|
NgIf,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ import { LucideAngularModule, BookOpen } from 'lucide-angular';
|
|||||||
import { CampaignService } from '../../../services/campaign.service';
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
import { CharacterService } from '../../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { NpcService } from '../../../services/npc.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
import { LayoutService } from '../../../services/layout.service';
|
||||||
import { Campaign } from '../../../services/campaign.model';
|
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
|
||||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||||
|
|
||||||
@@ -62,21 +61,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
|
|||||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||||
this.existingArcCount = treeData.arcs.length;
|
this.existingArcCount = treeData.arcs.length;
|
||||||
|
|
||||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
|
||||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.layoutService.show({
|
|
||||||
title: campaign.name,
|
|
||||||
items: buildCampaignTree(this.campaignId, treeData),
|
|
||||||
footerLabel: 'Toutes les campagnes',
|
|
||||||
createActions: [
|
|
||||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
|
||||||
],
|
|
||||||
globalItems,
|
|
||||||
globalBackLabel: 'Toutes les campagnes',
|
|
||||||
globalBackRoute: '/campaigns'
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +74,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
|
|||||||
order: this.existingArcCount + 1,
|
order: this.existingArcCount + 1,
|
||||||
icon: this.selectedIcon
|
icon: this.selectedIcon
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', created.id, 'edit']),
|
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', created.id]),
|
||||||
error: () => console.error('Erreur lors de la création de l\'arc')
|
error: () => console.error('Erreur lors de la création de l\'arc')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -99,6 +84,9 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,16 +9,17 @@ import { CampaignService } from '../../../services/campaign.service';
|
|||||||
import { CharacterService } from '../../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { NpcService } from '../../../services/npc.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { PageService } from '../../../services/page.service';
|
import { PageService } from '../../../services/page.service';
|
||||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
import { LayoutService } from '../../../services/layout.service';
|
||||||
import { PageTitleService } from '../../../services/page-title.service';
|
import { PageTitleService } from '../../../services/page-title.service';
|
||||||
import { Campaign, Arc } from '../../../services/campaign.model';
|
import { Arc } from '../../../services/campaign.model';
|
||||||
import { Page } from '../../../services/page.model';
|
import { Page } from '../../../services/page.model';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||||
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
|
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
|
||||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||||
|
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran de détail/modification d'un Arc.
|
* Écran de détail/modification d'un Arc.
|
||||||
@@ -78,7 +79,8 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
|||||||
private npcService: NpcService,
|
private npcService: NpcService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService,
|
||||||
|
private confirmDialog: ConfirmDialogService
|
||||||
) {
|
) {
|
||||||
this.form = this.fb.group({
|
this.form = this.fb.group({
|
||||||
name: ['', Validators.required],
|
name: ['', Validators.required],
|
||||||
@@ -142,21 +144,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
|||||||
resolution: arc.resolution ?? ''
|
resolution: arc.resolution ?? ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
|
||||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.layoutService.show({
|
|
||||||
title: campaign.name,
|
|
||||||
items: buildCampaignTree(this.campaignId, treeData),
|
|
||||||
footerLabel: 'Toutes les campagnes',
|
|
||||||
createActions: [
|
|
||||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
|
||||||
],
|
|
||||||
globalItems,
|
|
||||||
globalBackLabel: 'Toutes les campagnes',
|
|
||||||
globalBackRoute: '/campaigns'
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,10 +171,18 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
delete(): void {
|
delete(): void {
|
||||||
if (!confirm(`Supprimer l'arc "${this.arc?.name}" ? Cette action est irréversible.`)) return;
|
this.confirmDialog.confirm({
|
||||||
this.campaignService.deleteArc(this.arcId).subscribe({
|
title: 'Supprimer l\'arc',
|
||||||
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
message: `Supprimer l'arc "${this.arc?.name}" ?`,
|
||||||
error: () => console.error('Erreur lors de la suppression')
|
details: ['Cette action est irréversible.'],
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
|
this.campaignService.deleteArc(this.arcId).subscribe({
|
||||||
|
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
||||||
|
error: () => console.error('Erreur lors de la suppression')
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +191,9 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import { CampaignService } from '../../../services/campaign.service';
|
|||||||
import { CharacterService } from '../../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { NpcService } from '../../../services/npc.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { PageService } from '../../../services/page.service';
|
import { PageService } from '../../../services/page.service';
|
||||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
import { LayoutService } from '../../../services/layout.service';
|
||||||
import { PageTitleService } from '../../../services/page-title.service';
|
import { PageTitleService } from '../../../services/page-title.service';
|
||||||
import { Campaign, Arc } from '../../../services/campaign.model';
|
import { Arc } from '../../../services/campaign.model';
|
||||||
import { Page } from '../../../services/page.model';
|
import { Page } from '../../../services/page.model';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||||
|
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran de consultation d'un Arc narratif (lecture seule).
|
* Écran de consultation d'un Arc narratif (lecture seule).
|
||||||
@@ -50,7 +51,8 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
|||||||
private npcService: NpcService,
|
private npcService: NpcService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService,
|
||||||
|
private confirmDialog: ConfirmDialogService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -83,20 +85,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
|||||||
this.availablePages = pages;
|
this.availablePages = pages;
|
||||||
this.pageTitleService.set(arc.name);
|
this.pageTitleService.set(arc.name);
|
||||||
|
|
||||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
|
||||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
|
||||||
}));
|
|
||||||
this.layoutService.show({
|
|
||||||
title: campaign.name,
|
|
||||||
items: buildCampaignTree(this.campaignId, treeData),
|
|
||||||
footerLabel: 'Toutes les campagnes',
|
|
||||||
createActions: [
|
|
||||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
|
||||||
],
|
|
||||||
globalItems,
|
|
||||||
globalBackLabel: 'Toutes les campagnes',
|
|
||||||
globalBackRoute: '/campaigns'
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,18 +111,24 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
|||||||
if (impact.chapters > 0) parts.push(`${impact.chapters} chapitre${impact.chapters > 1 ? 's' : ''}`);
|
if (impact.chapters > 0) parts.push(`${impact.chapters} chapitre${impact.chapters > 1 ? 's' : ''}`);
|
||||||
if (impact.scenes > 0) parts.push(`${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}`);
|
if (impact.scenes > 0) parts.push(`${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}`);
|
||||||
|
|
||||||
const lines = [`Supprimer l'arc "${arc.name}" ?`];
|
const details: string[] = [];
|
||||||
if (parts.length) {
|
if (parts.length) {
|
||||||
lines.push('');
|
details.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
|
||||||
lines.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
|
|
||||||
}
|
}
|
||||||
lines.push('');
|
details.push('Cette action est irréversible.');
|
||||||
lines.push('Cette action est irréversible.');
|
|
||||||
|
|
||||||
if (!confirm(lines.join('\n'))) return;
|
this.confirmDialog.confirm({
|
||||||
this.campaignService.deleteArc(arc.id!).subscribe({
|
title: 'Supprimer l\'arc',
|
||||||
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
message: `Supprimer l'arc "${arc.name}" ?`,
|
||||||
error: () => console.error('Erreur lors de la suppression de l\'arc')
|
details,
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
|
this.campaignService.deleteArc(arc.id!).subscribe({
|
||||||
|
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
||||||
|
error: () => console.error('Erreur lors de la suppression de l\'arc')
|
||||||
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
error: () => console.error('Impossible de récupérer les dépendances de l\'arc')
|
error: () => console.error('Impossible de récupérer les dépendances de l\'arc')
|
||||||
@@ -141,6 +136,9 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { switchMap, map } from 'rxjs/operators';
|
|||||||
import { CampaignService } from '../services/campaign.service';
|
import { CampaignService } from '../services/campaign.service';
|
||||||
import { CharacterService } from '../services/character.service';
|
import { CharacterService } from '../services/character.service';
|
||||||
import { NpcService } from '../services/npc.service';
|
import { NpcService } from '../services/npc.service';
|
||||||
import { TreeItem } from '../services/layout.service';
|
import { TreeItem, SecondarySidebarConfig, GlobalItem } from '../services/layout.service';
|
||||||
import { Arc, Chapter, Scene } from '../services/campaign.model';
|
import { Arc, Chapter, Scene, Campaign } from '../services/campaign.model';
|
||||||
import { Character } from '../services/character.model';
|
import { Character } from '../services/character.model';
|
||||||
import { Npc } from '../services/npc.model';
|
import { Npc } from '../services/npc.model';
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
|||||||
const characterItems: TreeItem[] = sortedCharacters.map(ch => ({
|
const characterItems: TreeItem[] = sortedCharacters.map(ch => ({
|
||||||
id: `character-${ch.id}`,
|
id: `character-${ch.id}`,
|
||||||
label: ch.name,
|
label: ch.name,
|
||||||
route: `/campaigns/${campaignId}/characters/${ch.id}/edit`
|
route: `/campaigns/${campaignId}/characters/${ch.id}`
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const charactersNode: TreeItem = {
|
const charactersNode: TreeItem = {
|
||||||
@@ -107,7 +107,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
|||||||
const npcItems: TreeItem[] = sortedNpcs.map(n => ({
|
const npcItems: TreeItem[] = sortedNpcs.map(n => ({
|
||||||
id: `npc-${n.id}`,
|
id: `npc-${n.id}`,
|
||||||
label: n.name,
|
label: n.name,
|
||||||
route: `/campaigns/${campaignId}/npcs/${n.id}/edit`
|
route: `/campaigns/${campaignId}/npcs/${n.id}`
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const npcsNode: TreeItem = {
|
const npcsNode: TreeItem = {
|
||||||
@@ -172,3 +172,35 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
|||||||
|
|
||||||
return [...arcNodes, charactersNode, npcsNode];
|
return [...arcNodes, charactersNode, npcsNode];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit la SecondarySidebarConfig complete d'une campagne a partir des
|
||||||
|
* donnees deja chargees. A utiliser quand le composant fait deja un forkJoin
|
||||||
|
* pour ses propres donnees (arc-view, scene-edit, etc.) et a deja `campaign`,
|
||||||
|
* `allCampaigns` et `treeData` en main — evite de refaire les memes HTTP.
|
||||||
|
*
|
||||||
|
* Pour les composants qui n'ont pas d'autre fetch a faire (character-view,
|
||||||
|
* npc-view...), preferer CampaignSidebarService.show(campaignId) qui orchestre
|
||||||
|
* le forkJoin et appelle layoutService.show() en une seule ligne.
|
||||||
|
*/
|
||||||
|
export function buildCampaignSidebarConfig(
|
||||||
|
campaign: Campaign,
|
||||||
|
allCampaigns: Campaign[],
|
||||||
|
treeData: CampaignTreeData,
|
||||||
|
campaignId: string
|
||||||
|
): SecondarySidebarConfig {
|
||||||
|
const globalItems: GlobalItem[] = allCampaigns.map(c => ({
|
||||||
|
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
title: campaign.name,
|
||||||
|
items: buildCampaignTree(campaignId, treeData),
|
||||||
|
footerLabel: 'Toutes les campagnes',
|
||||||
|
createActions: [
|
||||||
|
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${campaignId}/arcs/create` }
|
||||||
|
],
|
||||||
|
globalItems,
|
||||||
|
globalBackLabel: 'Toutes les campagnes',
|
||||||
|
globalBackRoute: '/campaigns'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,14 +50,50 @@
|
|||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="campaign-game-system">Système de JDR</label>
|
<label for="campaign-game-system">Système de JDR</label>
|
||||||
<select id="campaign-game-system" formControlName="gameSystemId">
|
<select *ngIf="!creatingGameSystem" id="campaign-game-system" formControlName="gameSystemId">
|
||||||
<option value="">— Aucun (campagne générique) —</option>
|
<option value="">— Aucun (campagne générique) —</option>
|
||||||
<option *ngFor="let gs of availableGameSystems" [value]="gs.id">{{ gs.name }}</option>
|
<option *ngFor="let gs of availableGameSystems" [value]="gs.id">{{ gs.name }}</option>
|
||||||
|
<option [value]="CREATE_GAMESYSTEM_SENTINEL">+ Créer un nouveau système…</option>
|
||||||
</select>
|
</select>
|
||||||
<p class="hint">
|
|
||||||
|
<!-- Mode creation inline : remplace temporairement le select. -->
|
||||||
|
<div *ngIf="creatingGameSystem" class="inline-create">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="newGameSystemName"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
placeholder="Nom du nouveau système (ex: D&D 5e, Nimble, Maison)"
|
||||||
|
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
|
||||||
|
(keydown.escape)="cancelCreateGameSystem()"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<div class="inline-create-actions">
|
||||||
|
<button type="button" class="btn-inline-primary"
|
||||||
|
[disabled]="!newGameSystemName.trim() || creatingGameSystemInFlight"
|
||||||
|
(click)="submitCreateGameSystem()">
|
||||||
|
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
|
||||||
|
Créer
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-inline-secondary" (click)="cancelCreateGameSystem()">
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint">
|
||||||
|
Création rapide — vous pourrez ajouter les règles, les templates de fiches PJ/PNJ
|
||||||
|
et le reste depuis la section "Systèmes" plus tard.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p *ngIf="!creatingGameSystem" class="hint">
|
||||||
Optionnel. Si défini, l'IA injectera les règles du système (classes, combat, lore...)
|
Optionnel. Si défini, l'IA injectera les règles du système (classes, combat, lore...)
|
||||||
dans ses suggestions pour respecter les mécaniques du JDR.
|
dans ses suggestions pour respecter les mécaniques du JDR.
|
||||||
</p>
|
</p>
|
||||||
|
<p *ngIf="!creatingGameSystem" class="hint hint-warning">
|
||||||
|
⚠️ Le système de jeu choisi détermine aussi le <strong>template des fiches de PJ et PNJ</strong>.
|
||||||
|
Le changer plus tard rendra les champs des fiches existantes invisibles
|
||||||
|
(les données restent stockées mais ne s'afficheront qu'en revenant à l'ancien système).
|
||||||
|
Choisissez bien dès le départ si possible.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-box">
|
<div class="info-box">
|
||||||
|
|||||||
@@ -87,6 +87,81 @@ form {
|
|||||||
input[type="number"] { width: 120px; }
|
input[type="number"] { width: 120px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-create {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.875rem;
|
||||||
|
background: #1a2233;
|
||||||
|
border: 1px solid #2d3748;
|
||||||
|
border-left: 3px solid #6c63ff;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
background: #1f2937;
|
||||||
|
border: 1px solid #374151;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.6rem 0.875rem;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
|
||||||
|
&::placeholder { color: #4b5563; }
|
||||||
|
&:focus { border-color: #6c63ff; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-create-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-inline-primary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.5rem 0.875rem;
|
||||||
|
background: #6c63ff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) { background: #5b52e0; }
|
||||||
|
&:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-inline-secondary {
|
||||||
|
padding: 0.5rem 0.875rem;
|
||||||
|
background: transparent;
|
||||||
|
color: #9ca3af;
|
||||||
|
border: 1px solid #374151;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
|
||||||
|
&:hover { background: #1f2937; color: white; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-warning {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
background: rgba(234, 179, 8, 0.08);
|
||||||
|
border-left: 3px solid #eab308;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
color: #fbbf24;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
strong { color: #fde68a; }
|
||||||
|
}
|
||||||
|
|
||||||
.info-box {
|
.info-box {
|
||||||
background: #1f2937;
|
background: #1f2937;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Component, EventEmitter, OnInit, Output } from '@angular/core';
|
import { Component, EventEmitter, OnInit, Output } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { LucideAngularModule, BookCopy, X } from 'lucide-angular';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { LucideAngularModule, BookCopy, X, Plus, Check } from 'lucide-angular';
|
||||||
import { LoreService } from '../../../services/lore.service';
|
import { LoreService } from '../../../services/lore.service';
|
||||||
import { Lore } from '../../../services/lore.model';
|
import { Lore } from '../../../services/lore.model';
|
||||||
import { GameSystemService } from '../../../services/game-system.service';
|
import { GameSystemService } from '../../../services/game-system.service';
|
||||||
@@ -22,7 +23,7 @@ export interface CampaignCreatePayload {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-campaign-create',
|
selector: 'app-campaign-create',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule],
|
imports: [CommonModule, ReactiveFormsModule, FormsModule, LucideAngularModule],
|
||||||
templateUrl: './campaign-create.component.html',
|
templateUrl: './campaign-create.component.html',
|
||||||
styleUrls: ['./campaign-create.component.scss']
|
styleUrls: ['./campaign-create.component.scss']
|
||||||
})
|
})
|
||||||
@@ -32,6 +33,11 @@ export class CampaignCreateComponent implements OnInit {
|
|||||||
|
|
||||||
readonly BookCopy = BookCopy;
|
readonly BookCopy = BookCopy;
|
||||||
readonly X = X;
|
readonly X = X;
|
||||||
|
readonly Plus = Plus;
|
||||||
|
readonly Check = Check;
|
||||||
|
|
||||||
|
/** Valeur sentinelle de l'option "Creer un systeme" dans le <select>. */
|
||||||
|
readonly CREATE_GAMESYSTEM_SENTINEL = '__create__';
|
||||||
|
|
||||||
form: FormGroup;
|
form: FormGroup;
|
||||||
/** Lores disponibles pour association. Chargés à l'ouverture de la modal. */
|
/** Lores disponibles pour association. Chargés à l'ouverture de la modal. */
|
||||||
@@ -39,6 +45,11 @@ export class CampaignCreateComponent implements OnInit {
|
|||||||
/** GameSystems disponibles pour association. */
|
/** GameSystems disponibles pour association. */
|
||||||
availableGameSystems: GameSystem[] = [];
|
availableGameSystems: GameSystem[] = [];
|
||||||
|
|
||||||
|
/** Mode creation inline d'un GameSystem depuis le dropdown. */
|
||||||
|
creatingGameSystem = false;
|
||||||
|
newGameSystemName = '';
|
||||||
|
creatingGameSystemInFlight = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private fb: FormBuilder,
|
private fb: FormBuilder,
|
||||||
private loreService: LoreService,
|
private loreService: LoreService,
|
||||||
@@ -62,6 +73,47 @@ export class CampaignCreateComponent implements OnInit {
|
|||||||
next: (gs) => this.availableGameSystems = gs,
|
next: (gs) => this.availableGameSystems = gs,
|
||||||
error: () => this.availableGameSystems = []
|
error: () => this.availableGameSystems = []
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Detecte la selection de l'option sentinelle "Creer un systeme" et bascule
|
||||||
|
// en mode creation inline. On reinitialise immediatement le control a ''
|
||||||
|
// pour que la sentinelle ne reste pas en valeur reelle du form.
|
||||||
|
this.form.get('gameSystemId')?.valueChanges.subscribe(value => {
|
||||||
|
if (value === this.CREATE_GAMESYSTEM_SENTINEL) {
|
||||||
|
this.form.get('gameSystemId')?.setValue('', { emitEvent: false });
|
||||||
|
this.startCreateGameSystem();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
startCreateGameSystem(): void {
|
||||||
|
this.creatingGameSystem = true;
|
||||||
|
this.newGameSystemName = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelCreateGameSystem(): void {
|
||||||
|
this.creatingGameSystem = false;
|
||||||
|
this.newGameSystemName = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
submitCreateGameSystem(): void {
|
||||||
|
const name = this.newGameSystemName.trim();
|
||||||
|
if (!name || this.creatingGameSystemInFlight) return;
|
||||||
|
this.creatingGameSystemInFlight = true;
|
||||||
|
this.gameSystemService.create({ name, isPublic: false }).subscribe({
|
||||||
|
next: (created) => {
|
||||||
|
this.creatingGameSystemInFlight = false;
|
||||||
|
this.availableGameSystems = [...this.availableGameSystems, created];
|
||||||
|
if (created.id) {
|
||||||
|
this.form.get('gameSystemId')?.setValue(created.id);
|
||||||
|
}
|
||||||
|
this.creatingGameSystem = false;
|
||||||
|
this.newGameSystemName = '';
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.creatingGameSystemInFlight = false;
|
||||||
|
console.error('Erreur lors de la creation du systeme de jeu');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
|
|||||||
@@ -55,10 +55,37 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Système de JDR</label>
|
<label>Système de JDR</label>
|
||||||
<select [(ngModel)]="editGameSystemId" name="editGameSystemId">
|
<select *ngIf="!creatingGameSystem"
|
||||||
|
[(ngModel)]="editGameSystemId"
|
||||||
|
name="editGameSystemId"
|
||||||
|
(ngModelChange)="onEditGameSystemChange($event)">
|
||||||
<option value="">— Aucun (générique) —</option>
|
<option value="">— Aucun (générique) —</option>
|
||||||
<option *ngFor="let gs of availableGameSystems" [value]="gs.id">{{ gs.name }}</option>
|
<option *ngFor="let gs of availableGameSystems" [value]="gs.id">{{ gs.name }}</option>
|
||||||
|
<option [value]="CREATE_GAMESYSTEM_SENTINEL">+ Créer un nouveau système…</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<div *ngIf="creatingGameSystem" class="inline-create">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="newGameSystemName"
|
||||||
|
name="newGameSystemName"
|
||||||
|
placeholder="Nom du nouveau système (ex: D&D 5e, Nimble, Maison)"
|
||||||
|
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
|
||||||
|
(keydown.escape)="cancelCreateGameSystem()"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<div class="inline-create-actions">
|
||||||
|
<button type="button" class="btn-inline-primary"
|
||||||
|
[disabled]="!newGameSystemName.trim() || creatingGameSystemInFlight"
|
||||||
|
(click)="submitCreateGameSystem()">
|
||||||
|
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
|
||||||
|
Créer
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-inline-secondary" (click)="cancelCreateGameSystem()">
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button type="button" class="btn-primary" (click)="saveEdit()" [disabled]="!editName.trim()">
|
<button type="button" class="btn-primary" (click)="saveEdit()" [disabled]="!editName.trim()">
|
||||||
|
|||||||
@@ -122,6 +122,64 @@
|
|||||||
textarea { resize: vertical; }
|
textarea { resize: vertical; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-create {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.875rem;
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid #1f2937;
|
||||||
|
border-left: 3px solid #6c63ff;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
background: #0a1320;
|
||||||
|
border: 1px solid #1f2937;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&::placeholder { color: #4b5563; }
|
||||||
|
&:focus { border-color: #6c63ff; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-create-actions { display: flex; gap: 0.5rem; }
|
||||||
|
|
||||||
|
.btn-inline-primary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.45rem 0.875rem;
|
||||||
|
background: #6c63ff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) { background: #5b52e0; }
|
||||||
|
&:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-inline-secondary {
|
||||||
|
padding: 0.45rem 0.875rem;
|
||||||
|
background: transparent;
|
||||||
|
color: #9ca3af;
|
||||||
|
border: 1px solid #374151;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
|
||||||
|
&:hover { background: #1f2937; color: white; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.header-actions { justify-content: flex-end; }
|
.header-actions { justify-content: flex-end; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, User, Dices, Drama } from 'lucide-angular';
|
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, User, Dices, Drama, Check } from 'lucide-angular';
|
||||||
import { Router, RouterLink } from '@angular/router';
|
import { Router, RouterLink } from '@angular/router';
|
||||||
import { forkJoin, of } from 'rxjs';
|
import { forkJoin, of } from 'rxjs';
|
||||||
import { catchError, switchMap, filter, map } from 'rxjs/operators';
|
import { catchError, switchMap, filter, map } from 'rxjs/operators';
|
||||||
@@ -14,11 +14,12 @@ import { CharacterService } from '../../../services/character.service';
|
|||||||
import { NpcService } from '../../../services/npc.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { Character } from '../../../services/character.model';
|
import { Character } from '../../../services/character.model';
|
||||||
import { Npc } from '../../../services/npc.model';
|
import { Npc } from '../../../services/npc.model';
|
||||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
import { LayoutService } from '../../../services/layout.service';
|
||||||
import { PageTitleService } from '../../../services/page-title.service';
|
import { PageTitleService } from '../../../services/page-title.service';
|
||||||
import { Campaign, Arc } from '../../../services/campaign.model';
|
import { Campaign, Arc } from '../../../services/campaign.model';
|
||||||
import { Lore } from '../../../services/lore.model';
|
import { Lore } from '../../../services/lore.model';
|
||||||
import { loadCampaignTreeData, buildCampaignTree, CampaignTreeData } from '../../campaign-tree.helper';
|
import { loadCampaignTreeData, buildCampaignSidebarConfig, CampaignTreeData } from '../../campaign-tree.helper';
|
||||||
|
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-campaign-detail',
|
selector: 'app-campaign-detail',
|
||||||
@@ -36,6 +37,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
readonly User = User;
|
readonly User = User;
|
||||||
readonly Dices = Dices;
|
readonly Dices = Dices;
|
||||||
readonly Drama = Drama;
|
readonly Drama = Drama;
|
||||||
|
readonly Check = Check;
|
||||||
|
|
||||||
campaign: Campaign | null = null;
|
campaign: Campaign | null = null;
|
||||||
arcs: Arc[] = [];
|
arcs: Arc[] = [];
|
||||||
@@ -61,6 +63,13 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
editLoreId = '';
|
editLoreId = '';
|
||||||
editGameSystemId = '';
|
editGameSystemId = '';
|
||||||
|
|
||||||
|
/** Valeur sentinelle de l'option "Creer un systeme" dans le <select>. */
|
||||||
|
readonly CREATE_GAMESYSTEM_SENTINEL = '__create__';
|
||||||
|
/** Mode creation inline d'un GameSystem depuis le dropdown d'edition. */
|
||||||
|
creatingGameSystem = false;
|
||||||
|
newGameSystemName = '';
|
||||||
|
creatingGameSystemInFlight = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
@@ -70,7 +79,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
private characterService: CharacterService,
|
private characterService: CharacterService,
|
||||||
private npcService: NpcService,
|
private npcService: NpcService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService,
|
||||||
|
private confirmDialog: ConfirmDialogService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -241,24 +251,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private showLayout(allCampaigns: Campaign[], data: CampaignTreeData): void {
|
private showLayout(allCampaigns: Campaign[], data: CampaignTreeData): void {
|
||||||
const campaignId = this.campaign!.id!;
|
this.layoutService.show(buildCampaignSidebarConfig(this.campaign!, allCampaigns, data, this.campaign!.id!));
|
||||||
const globalItems: GlobalItem[] = allCampaigns.map(c => ({
|
|
||||||
id: c.id!,
|
|
||||||
name: c.name,
|
|
||||||
route: `/campaigns/${c.id}`
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.layoutService.show({
|
|
||||||
title: this.campaign!.name,
|
|
||||||
items: buildCampaignTree(campaignId, data),
|
|
||||||
footerLabel: 'Toutes les campagnes',
|
|
||||||
createActions: [
|
|
||||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${campaignId}/arcs/create` }
|
|
||||||
],
|
|
||||||
globalItems,
|
|
||||||
globalBackLabel: 'Toutes les campagnes',
|
|
||||||
globalBackRoute: '/campaigns'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────── Édition / suppression de la Campagne ───────────────
|
// ─────────────── Édition / suppression de la Campagne ───────────────
|
||||||
@@ -283,16 +276,83 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
cancelEdit(): void {
|
cancelEdit(): void {
|
||||||
this.editing = false;
|
this.editing = false;
|
||||||
|
this.creatingGameSystem = false;
|
||||||
|
this.newGameSystemName = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detecte la selection de l'option sentinelle dans le <select> GameSystem. */
|
||||||
|
onEditGameSystemChange(value: string): void {
|
||||||
|
if (value === this.CREATE_GAMESYSTEM_SENTINEL) {
|
||||||
|
this.editGameSystemId = '';
|
||||||
|
this.startCreateGameSystem();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startCreateGameSystem(): void {
|
||||||
|
this.creatingGameSystem = true;
|
||||||
|
this.newGameSystemName = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelCreateGameSystem(): void {
|
||||||
|
this.creatingGameSystem = false;
|
||||||
|
this.newGameSystemName = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
submitCreateGameSystem(): void {
|
||||||
|
const name = this.newGameSystemName.trim();
|
||||||
|
if (!name || this.creatingGameSystemInFlight) return;
|
||||||
|
this.creatingGameSystemInFlight = true;
|
||||||
|
this.gameSystemService.create({ name, isPublic: false }).subscribe({
|
||||||
|
next: (created) => {
|
||||||
|
this.creatingGameSystemInFlight = false;
|
||||||
|
this.availableGameSystems = [...this.availableGameSystems, created];
|
||||||
|
if (created.id) {
|
||||||
|
this.editGameSystemId = created.id;
|
||||||
|
}
|
||||||
|
this.creatingGameSystem = false;
|
||||||
|
this.newGameSystemName = '';
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.creatingGameSystemInFlight = false;
|
||||||
|
console.error('Erreur lors de la creation du systeme de jeu');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
saveEdit(): void {
|
saveEdit(): void {
|
||||||
if (!this.campaign || !this.editName.trim()) return;
|
if (!this.campaign || !this.editName.trim()) return;
|
||||||
|
const newGameSystemId = this.editGameSystemId ? this.editGameSystemId : null;
|
||||||
|
const currentGameSystemId = this.campaign.gameSystemId ?? null;
|
||||||
|
const gameSystemChanged = newGameSystemId !== currentGameSystemId;
|
||||||
|
const hasSheets = this.characters.length > 0 || this.npcs.length > 0;
|
||||||
|
if (gameSystemChanged && hasSheets) {
|
||||||
|
const count = this.characters.length + this.npcs.length;
|
||||||
|
this.confirmDialog.confirm({
|
||||||
|
title: 'Changer le systeme de jeu ?',
|
||||||
|
message:
|
||||||
|
`Vous etes sur le point de changer le systeme de jeu de cette campagne. ` +
|
||||||
|
`Cela change egalement le template des fiches de PJ et PNJ.`,
|
||||||
|
details: [
|
||||||
|
`${count} fiche(s) existante(s) sont liees au template du systeme actuel.`,
|
||||||
|
`Leurs champs ne s'afficheront plus avec le nouveau systeme.`,
|
||||||
|
`Les donnees restent stockees : revenir a l'ancien systeme les rendra a nouveau visibles.`
|
||||||
|
],
|
||||||
|
confirmLabel: 'Changer quand meme',
|
||||||
|
variant: 'warning'
|
||||||
|
}).then(ok => { if (ok) this.persistEdit(newGameSystemId); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.persistEdit(newGameSystemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private persistEdit(newGameSystemId: string | null): void {
|
||||||
|
if (!this.campaign) return;
|
||||||
this.campaignService.updateCampaign(this.campaign.id!, {
|
this.campaignService.updateCampaign(this.campaign.id!, {
|
||||||
name: this.editName.trim(),
|
name: this.editName.trim(),
|
||||||
description: this.editDescription,
|
description: this.editDescription,
|
||||||
playerCount: this.campaign.playerCount ?? 0,
|
playerCount: this.campaign.playerCount ?? 0,
|
||||||
loreId: this.editLoreId ? this.editLoreId : null,
|
loreId: this.editLoreId ? this.editLoreId : null,
|
||||||
gameSystemId: this.editGameSystemId ? this.editGameSystemId : null
|
gameSystemId: newGameSystemId
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: (updated) => {
|
next: (updated) => {
|
||||||
this.campaign = updated;
|
this.campaign = updated;
|
||||||
@@ -321,18 +381,22 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
if (impact.scenes > 0) parts.push(`${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}`);
|
if (impact.scenes > 0) parts.push(`${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}`);
|
||||||
if (impact.characters > 0) parts.push(`${impact.characters} personnage${impact.characters > 1 ? 's' : ''}`);
|
if (impact.characters > 0) parts.push(`${impact.characters} personnage${impact.characters > 1 ? 's' : ''}`);
|
||||||
|
|
||||||
const lines = [`Supprimer définitivement la campagne "${campaign.name}" ?`];
|
const details: string[] = [];
|
||||||
if (parts.length) {
|
if (parts.length) details.push(`Sera aussi supprime : ${parts.join(', ')}.`);
|
||||||
lines.push('');
|
details.push('Cette action est irreversible.');
|
||||||
lines.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
|
|
||||||
}
|
|
||||||
lines.push('');
|
|
||||||
lines.push('Cette action est irréversible.');
|
|
||||||
|
|
||||||
if (!confirm(lines.join('\n'))) return;
|
this.confirmDialog.confirm({
|
||||||
this.campaignService.deleteCampaign(campaign.id!).subscribe({
|
title: 'Supprimer la campagne ?',
|
||||||
next: () => this.router.navigate(['/campaigns']),
|
message: `Supprimer definitivement la campagne "${campaign.name}" ?`,
|
||||||
error: () => console.error('Erreur lors de la suppression de la campagne')
|
details,
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
|
this.campaignService.deleteCampaign(campaign.id!).subscribe({
|
||||||
|
next: () => this.router.navigate(['/campaigns']),
|
||||||
|
error: () => console.error('Erreur lors de la suppression de la campagne')
|
||||||
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
error: () => console.error('Impossible de récupérer les dépendances de la campagne')
|
error: () => console.error('Impossible de récupérer les dépendances de la campagne')
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ import { LucideAngularModule } from 'lucide-angular';
|
|||||||
import { CampaignService } from '../../../services/campaign.service';
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
import { CharacterService } from '../../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { NpcService } from '../../../services/npc.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
import { LayoutService } from '../../../services/layout.service';
|
||||||
import { Campaign } from '../../../services/campaign.model';
|
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
|
||||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||||
|
|
||||||
@@ -65,21 +64,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
|
|||||||
this.arcName = currentArc?.name ?? '';
|
this.arcName = currentArc?.name ?? '';
|
||||||
this.existingChapterCount = treeData.chaptersByArc[this.arcId]?.length ?? 0;
|
this.existingChapterCount = treeData.chaptersByArc[this.arcId]?.length ?? 0;
|
||||||
|
|
||||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
|
||||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.layoutService.show({
|
|
||||||
title: campaign.name,
|
|
||||||
items: buildCampaignTree(this.campaignId, treeData),
|
|
||||||
footerLabel: 'Toutes les campagnes',
|
|
||||||
createActions: [
|
|
||||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
|
||||||
],
|
|
||||||
globalItems,
|
|
||||||
globalBackLabel: 'Toutes les campagnes',
|
|
||||||
globalBackRoute: '/campaigns'
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +87,9 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,16 +9,17 @@ import { CampaignService } from '../../../services/campaign.service';
|
|||||||
import { CharacterService } from '../../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { NpcService } from '../../../services/npc.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { PageService } from '../../../services/page.service';
|
import { PageService } from '../../../services/page.service';
|
||||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
import { LayoutService } from '../../../services/layout.service';
|
||||||
import { PageTitleService } from '../../../services/page-title.service';
|
import { PageTitleService } from '../../../services/page-title.service';
|
||||||
import { Campaign, Chapter } from '../../../services/campaign.model';
|
import { Chapter } from '../../../services/campaign.model';
|
||||||
import { Page } from '../../../services/page.model';
|
import { Page } from '../../../services/page.model';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||||
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
|
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
|
||||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||||
|
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran de détail/modification d'un Chapitre.
|
* Écran de détail/modification d'un Chapitre.
|
||||||
@@ -71,7 +72,8 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
|||||||
private npcService: NpcService,
|
private npcService: NpcService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService,
|
||||||
|
private confirmDialog: ConfirmDialogService
|
||||||
) {
|
) {
|
||||||
this.form = this.fb.group({
|
this.form = this.fb.group({
|
||||||
name: ['', Validators.required],
|
name: ['', Validators.required],
|
||||||
@@ -130,21 +132,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
|||||||
narrativeStakes: chapter.narrativeStakes ?? ''
|
narrativeStakes: chapter.narrativeStakes ?? ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
|
||||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.layoutService.show({
|
|
||||||
title: campaign.name,
|
|
||||||
items: buildCampaignTree(this.campaignId, treeData),
|
|
||||||
footerLabel: 'Toutes les campagnes',
|
|
||||||
createActions: [
|
|
||||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
|
||||||
],
|
|
||||||
globalItems,
|
|
||||||
globalBackLabel: 'Toutes les campagnes',
|
|
||||||
globalBackRoute: '/campaigns'
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,10 +157,18 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
delete(): void {
|
delete(): void {
|
||||||
if (!confirm(`Supprimer le chapitre "${this.chapter?.name}" ? Cette action est irréversible.`)) return;
|
this.confirmDialog.confirm({
|
||||||
this.campaignService.deleteChapter(this.chapterId).subscribe({
|
title: 'Supprimer le chapitre',
|
||||||
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
message: `Supprimer le chapitre "${this.chapter?.name}" ?`,
|
||||||
error: () => console.error('Erreur lors de la suppression')
|
details: ['Cette action est irréversible.'],
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
|
this.campaignService.deleteChapter(this.chapterId).subscribe({
|
||||||
|
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
||||||
|
error: () => console.error('Erreur lors de la suppression')
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +177,9 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -371,6 +371,9 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import { CampaignService } from '../../../services/campaign.service';
|
|||||||
import { CharacterService } from '../../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { NpcService } from '../../../services/npc.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { PageService } from '../../../services/page.service';
|
import { PageService } from '../../../services/page.service';
|
||||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
import { LayoutService } from '../../../services/layout.service';
|
||||||
import { PageTitleService } from '../../../services/page-title.service';
|
import { PageTitleService } from '../../../services/page-title.service';
|
||||||
import { Campaign, Chapter } from '../../../services/campaign.model';
|
import { Chapter } from '../../../services/campaign.model';
|
||||||
import { Page } from '../../../services/page.model';
|
import { Page } from '../../../services/page.model';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||||
|
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran de consultation d'un Chapitre (lecture seule).
|
* Écran de consultation d'un Chapitre (lecture seule).
|
||||||
@@ -49,7 +50,8 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
|||||||
private npcService: NpcService,
|
private npcService: NpcService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService,
|
||||||
|
private confirmDialog: ConfirmDialogService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -86,20 +88,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
|||||||
this.availablePages = pages;
|
this.availablePages = pages;
|
||||||
this.pageTitleService.set(chapter.name);
|
this.pageTitleService.set(chapter.name);
|
||||||
|
|
||||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
|
||||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
|
||||||
}));
|
|
||||||
this.layoutService.show({
|
|
||||||
title: campaign.name,
|
|
||||||
items: buildCampaignTree(this.campaignId, treeData),
|
|
||||||
footerLabel: 'Toutes les campagnes',
|
|
||||||
createActions: [
|
|
||||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
|
||||||
],
|
|
||||||
globalItems,
|
|
||||||
globalBackLabel: 'Toutes les campagnes',
|
|
||||||
globalBackRoute: '/campaigns'
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,18 +117,24 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
|||||||
const chapter = this.chapter;
|
const chapter = this.chapter;
|
||||||
this.campaignService.getChapterDeletionImpact(chapter.id!).subscribe({
|
this.campaignService.getChapterDeletionImpact(chapter.id!).subscribe({
|
||||||
next: impact => {
|
next: impact => {
|
||||||
const lines = [`Supprimer le chapitre "${chapter.name}" ?`];
|
const details: string[] = [];
|
||||||
if (impact.scenes > 0) {
|
if (impact.scenes > 0) {
|
||||||
lines.push('');
|
details.push(`Cette action supprimera aussi : ${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}.`);
|
||||||
lines.push(`Cette action supprimera aussi : ${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}.`);
|
|
||||||
}
|
}
|
||||||
lines.push('');
|
details.push('Cette action est irréversible.');
|
||||||
lines.push('Cette action est irréversible.');
|
|
||||||
|
|
||||||
if (!confirm(lines.join('\n'))) return;
|
this.confirmDialog.confirm({
|
||||||
this.campaignService.deleteChapter(chapter.id!).subscribe({
|
title: 'Supprimer le chapitre',
|
||||||
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]),
|
message: `Supprimer le chapitre "${chapter.name}" ?`,
|
||||||
error: () => console.error('Erreur lors de la suppression du chapitre')
|
details,
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
|
this.campaignService.deleteChapter(chapter.id!).subscribe({
|
||||||
|
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]),
|
||||||
|
error: () => console.error('Erreur lors de la suppression du chapitre')
|
||||||
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
error: () => console.error('Impossible de récupérer les dépendances du chapitre')
|
error: () => console.error('Impossible de récupérer les dépendances du chapitre')
|
||||||
@@ -147,6 +142,9 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,9 @@
|
|||||||
<div class="ce-form">
|
<div class="ce-form">
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Nom du personnage *</label>
|
<label for="character-name">Nom du personnage *</label>
|
||||||
<input
|
<input
|
||||||
|
id="character-name"
|
||||||
type="text"
|
type="text"
|
||||||
[(ngModel)]="name"
|
[(ngModel)]="name"
|
||||||
name="name"
|
name="name"
|
||||||
@@ -61,8 +62,10 @@
|
|||||||
[fields]="templateFields"
|
[fields]="templateFields"
|
||||||
[values]="values"
|
[values]="values"
|
||||||
[imageValues]="imageValues"
|
[imageValues]="imageValues"
|
||||||
|
[keyValueValues]="keyValueValues"
|
||||||
(valuesChange)="values = $event"
|
(valuesChange)="values = $event"
|
||||||
(imageValuesChange)="imageValues = $event">
|
(imageValuesChange)="imageValues = $event"
|
||||||
|
(keyValueValuesChange)="keyValueValues = $event">
|
||||||
</app-dynamic-fields-form>
|
</app-dynamic-fields-form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lu
|
|||||||
import { CharacterService } from '../../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { CampaignService } from '../../../services/campaign.service';
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
import { GameSystemService } from '../../../services/game-system.service';
|
import { GameSystemService } from '../../../services/game-system.service';
|
||||||
|
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||||
import { TemplateField } from '../../../services/template.model';
|
import { TemplateField } from '../../../services/template.model';
|
||||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
|
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
|
||||||
import { SingleImagePickerComponent } from '../../../shared/single-image-picker/single-image-picker.component';
|
import { SingleImagePickerComponent } from '../../../shared/single-image-picker/single-image-picker.component';
|
||||||
|
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Editeur plein ecran d'une fiche de personnage (PJ).
|
* Editeur plein ecran d'une fiche de personnage (PJ).
|
||||||
@@ -53,6 +55,7 @@ export class CharacterEditComponent implements OnInit {
|
|||||||
headerImageId: string | null = null;
|
headerImageId: string | null = null;
|
||||||
values: Record<string, string> = {};
|
values: Record<string, string> = {};
|
||||||
imageValues: Record<string, string[]> = {};
|
imageValues: Record<string, string[]> = {};
|
||||||
|
keyValueValues: Record<string, Record<string, string>> = {};
|
||||||
templateFields: TemplateField[] = [];
|
templateFields: TemplateField[] = [];
|
||||||
private order = 0;
|
private order = 0;
|
||||||
|
|
||||||
@@ -61,7 +64,9 @@ export class CharacterEditComponent implements OnInit {
|
|||||||
private router: Router,
|
private router: Router,
|
||||||
private service: CharacterService,
|
private service: CharacterService,
|
||||||
private campaignService: CampaignService,
|
private campaignService: CampaignService,
|
||||||
private gameSystemService: GameSystemService
|
private gameSystemService: GameSystemService,
|
||||||
|
private campaignSidebar: CampaignSidebarService,
|
||||||
|
private confirmDialog: ConfirmDialogService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -71,6 +76,7 @@ export class CharacterEditComponent implements OnInit {
|
|||||||
|
|
||||||
if (this.campaignId) {
|
if (this.campaignId) {
|
||||||
this.loadTemplateForCampaign(this.campaignId);
|
this.loadTemplateForCampaign(this.campaignId);
|
||||||
|
this.campaignSidebar.show(this.campaignId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.characterId) {
|
if (this.characterId) {
|
||||||
@@ -81,6 +87,7 @@ export class CharacterEditComponent implements OnInit {
|
|||||||
this.headerImageId = c.headerImageId ?? null;
|
this.headerImageId = c.headerImageId ?? null;
|
||||||
this.values = c.values ?? {};
|
this.values = c.values ?? {};
|
||||||
this.imageValues = c.imageValues ?? {};
|
this.imageValues = c.imageValues ?? {};
|
||||||
|
this.keyValueValues = c.keyValueValues ?? {};
|
||||||
this.order = c.order ?? 0;
|
this.order = c.order ?? 0;
|
||||||
},
|
},
|
||||||
error: () => this.back()
|
error: () => this.back()
|
||||||
@@ -104,6 +111,7 @@ export class CharacterEditComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
if (!this.name.trim() || !this.campaignId) return;
|
if (!this.name.trim() || !this.campaignId) return;
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -112,23 +120,39 @@ export class CharacterEditComponent implements OnInit {
|
|||||||
headerImageId: this.headerImageId,
|
headerImageId: this.headerImageId,
|
||||||
values: this.values,
|
values: this.values,
|
||||||
imageValues: this.imageValues,
|
imageValues: this.imageValues,
|
||||||
|
keyValueValues: this.keyValueValues,
|
||||||
campaignId: this.campaignId
|
campaignId: this.campaignId
|
||||||
};
|
};
|
||||||
|
const isCreation = !this.characterId;
|
||||||
const req = this.characterId
|
const req = this.characterId
|
||||||
? this.service.update(this.characterId, { ...payload, id: this.characterId, order: this.order })
|
? this.service.update(this.characterId, { ...payload, id: this.characterId, order: this.order })
|
||||||
: this.service.create(payload);
|
: this.service.create(payload);
|
||||||
req.subscribe({
|
req.subscribe({
|
||||||
next: () => this.back(),
|
next: (saved) => {
|
||||||
|
if (isCreation && saved.id) {
|
||||||
|
this.router.navigate(['/campaigns', this.campaignId, 'characters', saved.id]);
|
||||||
|
} else {
|
||||||
|
this.back();
|
||||||
|
}
|
||||||
|
},
|
||||||
error: () => console.error('Erreur sauvegarde Character')
|
error: () => console.error('Erreur sauvegarde Character')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteCharacter(): void {
|
deleteCharacter(): void {
|
||||||
if (!this.characterId) return;
|
if (!this.characterId) return;
|
||||||
if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irreversible.`)) return;
|
this.confirmDialog.confirm({
|
||||||
this.service.delete(this.characterId).subscribe({
|
title: 'Supprimer la fiche ?',
|
||||||
next: () => this.back(),
|
message: `Supprimer la fiche de "${this.name}" ?`,
|
||||||
error: () => console.error('Erreur suppression Character')
|
details: ['Cette action est irreversible.'],
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok || !this.characterId) return;
|
||||||
|
this.service.delete(this.characterId).subscribe({
|
||||||
|
next: () => this.back(),
|
||||||
|
error: () => console.error('Erreur suppression Character')
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular'
|
|||||||
import { CharacterService } from '../../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { CampaignService } from '../../../services/campaign.service';
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
import { GameSystemService } from '../../../services/game-system.service';
|
import { GameSystemService } from '../../../services/game-system.service';
|
||||||
|
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||||
import { TemplateField } from '../../../services/template.model';
|
import { TemplateField } from '../../../services/template.model';
|
||||||
import { Character } from '../../../services/character.model';
|
import { Character } from '../../../services/character.model';
|
||||||
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
|
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
|
||||||
@@ -40,7 +41,8 @@ export class CharacterViewComponent implements OnInit {
|
|||||||
private router: Router,
|
private router: Router,
|
||||||
private service: CharacterService,
|
private service: CharacterService,
|
||||||
private campaignService: CampaignService,
|
private campaignService: CampaignService,
|
||||||
private gameSystemService: GameSystemService
|
private gameSystemService: GameSystemService,
|
||||||
|
private campaignSidebar: CampaignSidebarService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -54,6 +56,7 @@ export class CharacterViewComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (this.campaignId) {
|
if (this.campaignId) {
|
||||||
|
this.campaignSidebar.show(this.campaignId);
|
||||||
this.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
|
this.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
|
||||||
if (camp.gameSystemId) {
|
if (camp.gameSystemId) {
|
||||||
this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => {
|
this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => {
|
||||||
|
|||||||
@@ -26,8 +26,9 @@
|
|||||||
<div class="ne-form">
|
<div class="ne-form">
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Nom du PNJ *</label>
|
<label for="npc-name">Nom du PNJ *</label>
|
||||||
<input
|
<input
|
||||||
|
id="npc-name"
|
||||||
type="text"
|
type="text"
|
||||||
[(ngModel)]="name"
|
[(ngModel)]="name"
|
||||||
name="name"
|
name="name"
|
||||||
@@ -61,8 +62,10 @@
|
|||||||
[fields]="templateFields"
|
[fields]="templateFields"
|
||||||
[values]="values"
|
[values]="values"
|
||||||
[imageValues]="imageValues"
|
[imageValues]="imageValues"
|
||||||
|
[keyValueValues]="keyValueValues"
|
||||||
(valuesChange)="values = $event"
|
(valuesChange)="values = $event"
|
||||||
(imageValuesChange)="imageValues = $event">
|
(imageValuesChange)="imageValues = $event"
|
||||||
|
(keyValueValuesChange)="keyValueValues = $event">
|
||||||
</app-dynamic-fields-form>
|
</app-dynamic-fields-form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import { LucideAngularModule, Save, ArrowLeft, Drama, Trash2, Sparkles } from 'l
|
|||||||
import { NpcService } from '../../../services/npc.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { CampaignService } from '../../../services/campaign.service';
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
import { GameSystemService } from '../../../services/game-system.service';
|
import { GameSystemService } from '../../../services/game-system.service';
|
||||||
|
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||||
import { TemplateField } from '../../../services/template.model';
|
import { TemplateField } from '../../../services/template.model';
|
||||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
|
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
|
||||||
import { SingleImagePickerComponent } from '../../../shared/single-image-picker/single-image-picker.component';
|
import { SingleImagePickerComponent } from '../../../shared/single-image-picker/single-image-picker.component';
|
||||||
|
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Editeur plein ecran d'une fiche de PNJ.
|
* Editeur plein ecran d'une fiche de PNJ.
|
||||||
@@ -48,6 +50,7 @@ export class NpcEditComponent implements OnInit {
|
|||||||
headerImageId: string | null = null;
|
headerImageId: string | null = null;
|
||||||
values: Record<string, string> = {};
|
values: Record<string, string> = {};
|
||||||
imageValues: Record<string, string[]> = {};
|
imageValues: Record<string, string[]> = {};
|
||||||
|
keyValueValues: Record<string, Record<string, string>> = {};
|
||||||
templateFields: TemplateField[] = [];
|
templateFields: TemplateField[] = [];
|
||||||
private order = 0;
|
private order = 0;
|
||||||
|
|
||||||
@@ -56,7 +59,9 @@ export class NpcEditComponent implements OnInit {
|
|||||||
private router: Router,
|
private router: Router,
|
||||||
private service: NpcService,
|
private service: NpcService,
|
||||||
private campaignService: CampaignService,
|
private campaignService: CampaignService,
|
||||||
private gameSystemService: GameSystemService
|
private gameSystemService: GameSystemService,
|
||||||
|
private campaignSidebar: CampaignSidebarService,
|
||||||
|
private confirmDialog: ConfirmDialogService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -66,6 +71,7 @@ export class NpcEditComponent implements OnInit {
|
|||||||
|
|
||||||
if (this.campaignId) {
|
if (this.campaignId) {
|
||||||
this.loadTemplateForCampaign(this.campaignId);
|
this.loadTemplateForCampaign(this.campaignId);
|
||||||
|
this.campaignSidebar.show(this.campaignId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.npcId) {
|
if (this.npcId) {
|
||||||
@@ -76,6 +82,7 @@ export class NpcEditComponent implements OnInit {
|
|||||||
this.headerImageId = n.headerImageId ?? null;
|
this.headerImageId = n.headerImageId ?? null;
|
||||||
this.values = n.values ?? {};
|
this.values = n.values ?? {};
|
||||||
this.imageValues = n.imageValues ?? {};
|
this.imageValues = n.imageValues ?? {};
|
||||||
|
this.keyValueValues = n.keyValueValues ?? {};
|
||||||
this.order = n.order ?? 0;
|
this.order = n.order ?? 0;
|
||||||
},
|
},
|
||||||
error: () => this.back()
|
error: () => this.back()
|
||||||
@@ -99,6 +106,7 @@ export class NpcEditComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
if (!this.name.trim() || !this.campaignId) return;
|
if (!this.name.trim() || !this.campaignId) return;
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -107,23 +115,39 @@ export class NpcEditComponent implements OnInit {
|
|||||||
headerImageId: this.headerImageId,
|
headerImageId: this.headerImageId,
|
||||||
values: this.values,
|
values: this.values,
|
||||||
imageValues: this.imageValues,
|
imageValues: this.imageValues,
|
||||||
|
keyValueValues: this.keyValueValues,
|
||||||
campaignId: this.campaignId
|
campaignId: this.campaignId
|
||||||
};
|
};
|
||||||
|
const isCreation = !this.npcId;
|
||||||
const req = this.npcId
|
const req = this.npcId
|
||||||
? this.service.update(this.npcId, { ...payload, id: this.npcId, order: this.order })
|
? this.service.update(this.npcId, { ...payload, id: this.npcId, order: this.order })
|
||||||
: this.service.create(payload);
|
: this.service.create(payload);
|
||||||
req.subscribe({
|
req.subscribe({
|
||||||
next: () => this.back(),
|
next: (saved) => {
|
||||||
|
if (isCreation && saved.id) {
|
||||||
|
this.router.navigate(['/campaigns', this.campaignId, 'npcs', saved.id]);
|
||||||
|
} else {
|
||||||
|
this.back();
|
||||||
|
}
|
||||||
|
},
|
||||||
error: () => console.error('Erreur sauvegarde Npc')
|
error: () => console.error('Erreur sauvegarde Npc')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteNpc(): void {
|
deleteNpc(): void {
|
||||||
if (!this.npcId) return;
|
if (!this.npcId) return;
|
||||||
if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irreversible.`)) return;
|
this.confirmDialog.confirm({
|
||||||
this.service.delete(this.npcId).subscribe({
|
title: 'Supprimer la fiche ?',
|
||||||
next: () => this.back(),
|
message: `Supprimer la fiche de "${this.name}" ?`,
|
||||||
error: () => console.error('Erreur suppression Npc')
|
details: ['Cette action est irreversible.'],
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok || !this.npcId) return;
|
||||||
|
this.service.delete(this.npcId).subscribe({
|
||||||
|
next: () => this.back(),
|
||||||
|
error: () => console.error('Erreur suppression Npc')
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular'
|
|||||||
import { NpcService } from '../../../services/npc.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { CampaignService } from '../../../services/campaign.service';
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
import { GameSystemService } from '../../../services/game-system.service';
|
import { GameSystemService } from '../../../services/game-system.service';
|
||||||
|
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||||
import { TemplateField } from '../../../services/template.model';
|
import { TemplateField } from '../../../services/template.model';
|
||||||
import { Npc } from '../../../services/npc.model';
|
import { Npc } from '../../../services/npc.model';
|
||||||
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
|
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
|
||||||
@@ -40,7 +41,8 @@ export class NpcViewComponent implements OnInit {
|
|||||||
private router: Router,
|
private router: Router,
|
||||||
private service: NpcService,
|
private service: NpcService,
|
||||||
private campaignService: CampaignService,
|
private campaignService: CampaignService,
|
||||||
private gameSystemService: GameSystemService
|
private gameSystemService: GameSystemService,
|
||||||
|
private campaignSidebar: CampaignSidebarService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -54,6 +56,7 @@ export class NpcViewComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (this.campaignId) {
|
if (this.campaignId) {
|
||||||
|
this.campaignSidebar.show(this.campaignId);
|
||||||
this.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
|
this.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
|
||||||
if (camp.gameSystemId) {
|
if (camp.gameSystemId) {
|
||||||
this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => {
|
this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => {
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ import { LucideAngularModule } from 'lucide-angular';
|
|||||||
import { CampaignService } from '../../../services/campaign.service';
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
import { CharacterService } from '../../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { NpcService } from '../../../services/npc.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
import { LayoutService } from '../../../services/layout.service';
|
||||||
import { Campaign } from '../../../services/campaign.model';
|
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
|
||||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||||
|
|
||||||
@@ -67,21 +66,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
|
|||||||
this.chapterName = currentChapter?.name ?? '';
|
this.chapterName = currentChapter?.name ?? '';
|
||||||
this.existingSceneCount = treeData.scenesByChapter[this.chapterId]?.length ?? 0;
|
this.existingSceneCount = treeData.scenesByChapter[this.chapterId]?.length ?? 0;
|
||||||
|
|
||||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
|
||||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.layoutService.show({
|
|
||||||
title: campaign.name,
|
|
||||||
items: buildCampaignTree(this.campaignId, treeData),
|
|
||||||
footerLabel: 'Toutes les campagnes',
|
|
||||||
createActions: [
|
|
||||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
|
||||||
],
|
|
||||||
globalItems,
|
|
||||||
globalBackLabel: 'Toutes les campagnes',
|
|
||||||
globalBackRoute: '/campaigns'
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +79,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
|
|||||||
order: this.existingSceneCount + 1,
|
order: this.existingSceneCount + 1,
|
||||||
icon: this.selectedIcon
|
icon: this.selectedIcon
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', created.id, 'edit']),
|
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', created.id]),
|
||||||
error: () => console.error('Erreur lors de la création de la scène')
|
error: () => console.error('Erreur lors de la création de la scène')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -104,6 +89,9 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,17 +9,18 @@ import { CampaignService } from '../../../services/campaign.service';
|
|||||||
import { CharacterService } from '../../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { NpcService } from '../../../services/npc.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { PageService } from '../../../services/page.service';
|
import { PageService } from '../../../services/page.service';
|
||||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
import { LayoutService } from '../../../services/layout.service';
|
||||||
import { PageTitleService } from '../../../services/page-title.service';
|
import { PageTitleService } from '../../../services/page-title.service';
|
||||||
import { Campaign, Scene, SceneBranch } from '../../../services/campaign.model';
|
import { Scene, SceneBranch } from '../../../services/campaign.model';
|
||||||
import { Page } from '../../../services/page.model';
|
import { Page } from '../../../services/page.model';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||||
import { ExpandableSectionComponent } from '../../../shared/expandable-section/expandable-section.component';
|
import { ExpandableSectionComponent } from '../../../shared/expandable-section/expandable-section.component';
|
||||||
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
|
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
|
||||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||||
|
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran de détail/modification d'une Scène.
|
* Écran de détail/modification d'une Scène.
|
||||||
@@ -75,7 +76,8 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
|||||||
private npcService: NpcService,
|
private npcService: NpcService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService,
|
||||||
|
private confirmDialog: ConfirmDialogService
|
||||||
) {
|
) {
|
||||||
this.form = this.fb.group({
|
this.form = this.fb.group({
|
||||||
name: ['', Validators.required],
|
name: ['', Validators.required],
|
||||||
@@ -155,21 +157,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
|||||||
enemies: scene.enemies ?? ''
|
enemies: scene.enemies ?? ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
|
||||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.layoutService.show({
|
|
||||||
title: campaign.name,
|
|
||||||
items: buildCampaignTree(this.campaignId, treeData),
|
|
||||||
footerLabel: 'Toutes les campagnes',
|
|
||||||
createActions: [
|
|
||||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
|
||||||
],
|
|
||||||
globalItems,
|
|
||||||
globalBackLabel: 'Toutes les campagnes',
|
|
||||||
globalBackRoute: '/campaigns'
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,10 +188,18 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
delete(): void {
|
delete(): void {
|
||||||
if (!confirm(`Supprimer la scène "${this.scene?.name}" ? Cette action est irréversible.`)) return;
|
this.confirmDialog.confirm({
|
||||||
this.campaignService.deleteScene(this.sceneId).subscribe({
|
title: 'Supprimer la scène',
|
||||||
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
message: `Supprimer la scène "${this.scene?.name}" ?`,
|
||||||
error: () => console.error('Erreur lors de la suppression')
|
details: ['Cette action est irréversible.'],
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
|
this.campaignService.deleteScene(this.sceneId).subscribe({
|
||||||
|
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
||||||
|
error: () => console.error('Erreur lors de la suppression')
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,6 +232,9 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import { CampaignService } from '../../../services/campaign.service';
|
|||||||
import { CharacterService } from '../../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { NpcService } from '../../../services/npc.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { PageService } from '../../../services/page.service';
|
import { PageService } from '../../../services/page.service';
|
||||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
import { LayoutService } from '../../../services/layout.service';
|
||||||
import { PageTitleService } from '../../../services/page-title.service';
|
import { PageTitleService } from '../../../services/page-title.service';
|
||||||
import { Campaign, Scene } from '../../../services/campaign.model';
|
import { Scene } from '../../../services/campaign.model';
|
||||||
import { Page } from '../../../services/page.model';
|
import { Page } from '../../../services/page.model';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||||
|
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran de consultation d'une Scène (lecture seule).
|
* Écran de consultation d'une Scène (lecture seule).
|
||||||
@@ -49,7 +50,8 @@ export class SceneViewComponent implements OnInit, OnDestroy {
|
|||||||
private npcService: NpcService,
|
private npcService: NpcService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService,
|
||||||
|
private confirmDialog: ConfirmDialogService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -89,20 +91,7 @@ export class SceneViewComponent implements OnInit, OnDestroy {
|
|||||||
this.availablePages = pages;
|
this.availablePages = pages;
|
||||||
this.pageTitleService.set(scene.name);
|
this.pageTitleService.set(scene.name);
|
||||||
|
|
||||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
|
||||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
|
||||||
}));
|
|
||||||
this.layoutService.show({
|
|
||||||
title: campaign.name,
|
|
||||||
items: buildCampaignTree(this.campaignId, treeData),
|
|
||||||
footerLabel: 'Toutes les campagnes',
|
|
||||||
createActions: [
|
|
||||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
|
||||||
],
|
|
||||||
globalItems,
|
|
||||||
globalBackLabel: 'Toutes les campagnes',
|
|
||||||
globalBackRoute: '/campaigns'
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,16 +110,27 @@ export class SceneViewComponent implements OnInit, OnDestroy {
|
|||||||
deleteScene(): void {
|
deleteScene(): void {
|
||||||
if (!this.scene) return;
|
if (!this.scene) return;
|
||||||
const scene = this.scene;
|
const scene = this.scene;
|
||||||
if (!confirm(`Supprimer la scène "${scene.name}" ?\n\nCette action est irréversible.`)) return;
|
this.confirmDialog.confirm({
|
||||||
this.campaignService.deleteScene(scene.id!).subscribe({
|
title: 'Supprimer la scène',
|
||||||
next: () => this.router.navigate([
|
message: `Supprimer la scène "${scene.name}" ?`,
|
||||||
'/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId
|
details: ['Cette action est irréversible.'],
|
||||||
]),
|
confirmLabel: 'Supprimer',
|
||||||
error: () => console.error('Erreur lors de la suppression de la scène')
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
|
this.campaignService.deleteScene(scene.id!).subscribe({
|
||||||
|
next: () => this.router.navigate([
|
||||||
|
'/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId
|
||||||
|
]),
|
||||||
|
error: () => console.error('Erreur lors de la suppression de la scène')
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,18 +14,18 @@
|
|||||||
<div class="gse-form">
|
<div class="gse-form">
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Nom *</label>
|
<label for="gs-name">Nom *</label>
|
||||||
<input type="text" [(ngModel)]="name" name="name" placeholder="Ex: Nimble, D&D 5.1 SRD, Mon Homebrew..." />
|
<input id="gs-name" type="text" [(ngModel)]="name" name="name" placeholder="Ex: Nimble, D&D 5.1 SRD, Mon Homebrew..." />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Description courte</label>
|
<label for="gs-description">Description courte</label>
|
||||||
<textarea [(ngModel)]="description" name="description" rows="2" placeholder="En une ligne, de quoi parle ce système ?"></textarea>
|
<textarea id="gs-description" [(ngModel)]="description" name="description" rows="2" placeholder="En une ligne, de quoi parle ce système ?"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Auteur</label>
|
<label for="gs-author">Auteur</label>
|
||||||
<input type="text" [(ngModel)]="author" name="author" placeholder="Ex: Hasbro, Homebrew, moi-même..." />
|
<input id="gs-author" type="text" [(ngModel)]="author" name="author" placeholder="Ex: Hasbro, Homebrew, moi-même..." />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sections de règles -->
|
<!-- Sections de règles -->
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Router } from '@angular/router';
|
|||||||
import { LucideAngularModule, Dices, Plus, Pencil, Trash2 } from 'lucide-angular';
|
import { LucideAngularModule, Dices, Plus, Pencil, Trash2 } from 'lucide-angular';
|
||||||
import { GameSystemService } from '../services/game-system.service';
|
import { GameSystemService } from '../services/game-system.service';
|
||||||
import { GameSystem } from '../services/game-system.model';
|
import { GameSystem } from '../services/game-system.model';
|
||||||
|
import { ConfirmDialogService } from '../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-game-systems',
|
selector: 'app-game-systems',
|
||||||
@@ -22,7 +23,8 @@ export class GameSystemsComponent implements OnInit {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private gameSystemService: GameSystemService
|
private gameSystemService: GameSystemService,
|
||||||
|
private confirmDialog: ConfirmDialogService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -47,10 +49,18 @@ export class GameSystemsComponent implements OnInit {
|
|||||||
delete(system: GameSystem, event: MouseEvent): void {
|
delete(system: GameSystem, event: MouseEvent): void {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (!system.id) return;
|
if (!system.id) return;
|
||||||
if (!confirm(`Supprimer le système "${system.name}" ? Les campagnes qui l'utilisent ne seront plus associées à aucun système.`)) return;
|
this.confirmDialog.confirm({
|
||||||
this.gameSystemService.delete(system.id).subscribe({
|
title: 'Supprimer le système',
|
||||||
next: () => this.load(),
|
message: `Supprimer le système "${system.name}" ?`,
|
||||||
error: () => console.error('Erreur suppression GameSystem')
|
details: ['Les campagnes qui l\'utilisent ne seront plus associées à aucun système.'],
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok || !system.id) return;
|
||||||
|
this.gameSystemService.delete(system.id).subscribe({
|
||||||
|
next: () => this.load(),
|
||||||
|
error: () => console.error('Erreur suppression GameSystem')
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Lore, LoreNode } from '../../services/lore.model';
|
|||||||
import { Page } from '../../services/page.model';
|
import { Page } from '../../services/page.model';
|
||||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||||
import { resolveIcon } from '../lore-icons';
|
import { resolveIcon } from '../lore-icons';
|
||||||
|
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vue "détail" d'un dossier : affiche son contenu (sous-dossiers + pages) et
|
* Vue "détail" d'un dossier : affiche son contenu (sous-dossiers + pages) et
|
||||||
@@ -52,7 +53,8 @@ export class FolderViewComponent implements OnInit, OnDestroy {
|
|||||||
private templateService: TemplateService,
|
private templateService: TemplateService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService,
|
||||||
|
private confirmDialog: ConfirmDialogService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -148,25 +150,31 @@ export class FolderViewComponent implements OnInit, OnDestroy {
|
|||||||
if (impact.folders > 0) parts.push(`${impact.folders} sous-dossier${impact.folders > 1 ? 's' : ''}`);
|
if (impact.folders > 0) parts.push(`${impact.folders} sous-dossier${impact.folders > 1 ? 's' : ''}`);
|
||||||
if (impact.pages > 0) parts.push(`${impact.pages} page${impact.pages > 1 ? 's' : ''}`);
|
if (impact.pages > 0) parts.push(`${impact.pages} page${impact.pages > 1 ? 's' : ''}`);
|
||||||
|
|
||||||
const lines = [`Supprimer le dossier "${node.name}" ?`];
|
const details: string[] = [];
|
||||||
if (parts.length) {
|
if (parts.length) {
|
||||||
lines.push('');
|
details.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
|
||||||
lines.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
|
|
||||||
}
|
}
|
||||||
lines.push('');
|
details.push('Cette action est irréversible.');
|
||||||
lines.push('Cette action est irréversible.');
|
|
||||||
|
|
||||||
if (!confirm(lines.join('\n'))) return;
|
this.confirmDialog.confirm({
|
||||||
this.loreService.deleteLoreNode(this.folderId).subscribe({
|
title: 'Supprimer le dossier',
|
||||||
next: () => {
|
message: `Supprimer le dossier "${node.name}" ?`,
|
||||||
// Remonte au dossier parent si présent, sinon au Lore.
|
details,
|
||||||
if (node.parentId) {
|
confirmLabel: 'Supprimer',
|
||||||
this.router.navigate(['/lore', this.loreId, 'folders', node.parentId]);
|
variant: 'danger'
|
||||||
} else {
|
}).then(ok => {
|
||||||
this.router.navigate(['/lore', this.loreId]);
|
if (!ok) return;
|
||||||
}
|
this.loreService.deleteLoreNode(this.folderId).subscribe({
|
||||||
},
|
next: () => {
|
||||||
error: () => console.error('Erreur lors de la suppression du dossier')
|
// Remonte au dossier parent si présent, sinon au Lore.
|
||||||
|
if (node.parentId) {
|
||||||
|
this.router.navigate(['/lore', this.loreId, 'folders', node.parentId]);
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/lore', this.loreId]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => console.error('Erreur lors de la suppression du dossier')
|
||||||
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
error: () => console.error('Impossible de récupérer les dépendances du dossier')
|
error: () => console.error('Impossible de récupérer les dépendances du dossier')
|
||||||
@@ -174,6 +182,9 @@ export class FolderViewComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { LayoutService } from '../../services/layout.service';
|
|||||||
import { PageTitleService } from '../../services/page-title.service';
|
import { PageTitleService } from '../../services/page-title.service';
|
||||||
import { Lore, LoreNode } from '../../services/lore.model';
|
import { Lore, LoreNode } from '../../services/lore.model';
|
||||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||||
|
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-lore-detail',
|
selector: 'app-lore-detail',
|
||||||
@@ -42,7 +43,8 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
|
|||||||
private templateService: TemplateService,
|
private templateService: TemplateService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService,
|
||||||
|
private confirmDialog: ConfirmDialogService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -125,25 +127,30 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
|
|||||||
if (impact.pages > 0) deleted.push(`${impact.pages} page${impact.pages > 1 ? 's' : ''}`);
|
if (impact.pages > 0) deleted.push(`${impact.pages} page${impact.pages > 1 ? 's' : ''}`);
|
||||||
if (impact.templates > 0) deleted.push(`${impact.templates} template${impact.templates > 1 ? 's' : ''}`);
|
if (impact.templates > 0) deleted.push(`${impact.templates} template${impact.templates > 1 ? 's' : ''}`);
|
||||||
|
|
||||||
const lines = [`Supprimer définitivement le Lore "${lore.name}" ?`];
|
const details: string[] = [];
|
||||||
if (deleted.length) {
|
if (deleted.length) {
|
||||||
lines.push('');
|
details.push(`Cette action supprimera aussi : ${deleted.join(', ')}.`);
|
||||||
lines.push(`Cette action supprimera aussi : ${deleted.join(', ')}.`);
|
|
||||||
}
|
}
|
||||||
if (impact.detachedCampaigns > 0) {
|
if (impact.detachedCampaigns > 0) {
|
||||||
lines.push('');
|
details.push(
|
||||||
lines.push(
|
|
||||||
`${impact.detachedCampaigns} campagne${impact.detachedCampaigns > 1 ? 's' : ''} ${impact.detachedCampaigns > 1 ? 'seront conservées' : 'sera conservée'} ` +
|
`${impact.detachedCampaigns} campagne${impact.detachedCampaigns > 1 ? 's' : ''} ${impact.detachedCampaigns > 1 ? 'seront conservées' : 'sera conservée'} ` +
|
||||||
`mais ${impact.detachedCampaigns > 1 ? 'perdront' : 'perdra'} leur lien vers cet univers.`
|
`mais ${impact.detachedCampaigns > 1 ? 'perdront' : 'perdra'} leur lien vers cet univers.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
lines.push('');
|
details.push('Cette action est irréversible.');
|
||||||
lines.push('Cette action est irréversible.');
|
|
||||||
|
|
||||||
if (!confirm(lines.join('\n'))) return;
|
this.confirmDialog.confirm({
|
||||||
this.loreService.deleteLore(lore.id!).subscribe({
|
title: 'Supprimer le Lore',
|
||||||
next: () => this.router.navigate(['/lore']),
|
message: `Supprimer définitivement le Lore "${lore.name}" ?`,
|
||||||
error: () => console.error('Erreur lors de la suppression du Lore')
|
details,
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
|
this.loreService.deleteLore(lore.id!).subscribe({
|
||||||
|
next: () => this.router.navigate(['/lore']),
|
||||||
|
error: () => console.error('Erreur lors de la suppression du Lore')
|
||||||
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
error: () => console.error('Impossible de récupérer les dépendances du Lore')
|
error: () => console.error('Impossible de récupérer les dépendances du Lore')
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ export class LoreNodeCreateComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,6 +147,9 @@ export class LoreNodeEditComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,23 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="template-description">{{ t.description || '—' }}</p>
|
<p class="template-description">{{ t.description || '—' }}</p>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Carte "+" : sauvegarde le brouillon et part creer un nouveau template ;
|
||||||
|
template-create renverra ici via le mecanisme returnTo. -->
|
||||||
|
<a
|
||||||
|
class="template-card template-card-create"
|
||||||
|
[routerLink]="['/lore', loreId, 'templates', 'create']"
|
||||||
|
[queryParams]="{ returnTo: 'page-create' }"
|
||||||
|
(click)="saveDraft()"
|
||||||
|
title="Créer un nouveau template pour ce Lore">
|
||||||
|
<div class="template-card-head">
|
||||||
|
<lucide-icon [img]="Plus" [size]="16"></lucide-icon>
|
||||||
|
<span class="template-name">Créer un template</span>
|
||||||
|
</div>
|
||||||
|
<p class="template-description">
|
||||||
|
Vous reviendrez ici automatiquement, votre saisie sera conservée.
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ng-template #emptyTemplates>
|
<ng-template #emptyTemplates>
|
||||||
|
|||||||
@@ -116,6 +116,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Carte "+" pour creer un nouveau template depuis l'ecran de creation de page.
|
||||||
|
// Bordure pointillee + couleurs attenuees pour la distinguer visuellement des
|
||||||
|
// vraies cartes selectionnables (et indiquer que c'est une action, pas un
|
||||||
|
// element de donnees).
|
||||||
|
.template-card-create {
|
||||||
|
border-style: dashed !important;
|
||||||
|
border-color: #3a3a55 !important;
|
||||||
|
background: transparent !important;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
.template-card-head {
|
||||||
|
color: #d1a878;
|
||||||
|
.template-name { color: #d1a878; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #d1a878 !important;
|
||||||
|
background: rgba(209, 168, 120, 0.05) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.info-box {
|
.info-box {
|
||||||
background: #1a1a2e;
|
background: #1a1a2e;
|
||||||
border: 1px solid #2a2a3d;
|
border: 1px solid #2a2a3d;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
import { LucideAngularModule, FileText, Sparkles } from 'lucide-angular';
|
import { LucideAngularModule, FileText, Sparkles, Plus } from 'lucide-angular';
|
||||||
import { LoreService } from '../../services/lore.service';
|
import { LoreService } from '../../services/lore.service';
|
||||||
import { TemplateService } from '../../services/template.service';
|
import { TemplateService } from '../../services/template.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { PageService } from '../../services/page.service';
|
||||||
@@ -34,6 +34,7 @@ import { AiChatDrawerComponent, ChatPrimaryAction } from '../../shared/ai-chat-d
|
|||||||
export class PageCreateComponent implements OnInit, OnDestroy {
|
export class PageCreateComponent implements OnInit, OnDestroy {
|
||||||
readonly FileText = FileText;
|
readonly FileText = FileText;
|
||||||
readonly Sparkles = Sparkles;
|
readonly Sparkles = Sparkles;
|
||||||
|
readonly Plus = Plus;
|
||||||
|
|
||||||
form: FormGroup;
|
form: FormGroup;
|
||||||
loreId = '';
|
loreId = '';
|
||||||
@@ -117,6 +118,22 @@ export class PageCreateComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.restoreDraft();
|
this.restoreDraft();
|
||||||
|
|
||||||
|
// Retour depuis template-create avec selectTemplateId=ID : selectionne
|
||||||
|
// automatiquement le template fraichement cree (gagne sur restoreDraft).
|
||||||
|
const selectId = this.route.snapshot.queryParamMap.get('selectTemplateId');
|
||||||
|
if (selectId) {
|
||||||
|
const tpl = this.templates.find(t => t.id === selectId);
|
||||||
|
if (tpl) this.selectTemplate(tpl);
|
||||||
|
// On nettoie le query-param pour ne pas re-selectionner si la page
|
||||||
|
// est rechargee plus tard.
|
||||||
|
this.router.navigate([], {
|
||||||
|
relativeTo: this.route,
|
||||||
|
queryParams: { selectTemplateId: null },
|
||||||
|
queryParamsHandling: 'merge',
|
||||||
|
replaceUrl: true
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,6 +339,9 @@ Les clés du JSON doivent correspondre EXACTEMENT aux noms de champs indiqués.
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/bre
|
|||||||
import { AiChatDrawerComponent, ChatPrimaryAction } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
import { AiChatDrawerComponent, ChatPrimaryAction } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
||||||
import { Lore } from '../../services/lore.model';
|
import { Lore } from '../../services/lore.model';
|
||||||
|
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran d'édition d'une Page.
|
* Écran d'édition d'une Page.
|
||||||
@@ -90,7 +91,8 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
|||||||
private templateService: TemplateService,
|
private templateService: TemplateService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService,
|
||||||
|
private confirmDialog: ConfirmDialogService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -258,14 +260,24 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
delete(): void {
|
delete(): void {
|
||||||
if (!this.page) return;
|
if (!this.page) return;
|
||||||
if (!confirm(`Supprimer la page "${this.page.title}" ?`)) return;
|
this.confirmDialog.confirm({
|
||||||
this.pageService.delete(this.pageId).subscribe({
|
title: 'Supprimer la page',
|
||||||
next: () => this.router.navigate(['/lore', this.loreId]),
|
message: `Supprimer la page "${this.page.title}" ?`,
|
||||||
error: () => console.error('Erreur lors de la suppression de la page')
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok || !this.page) return;
|
||||||
|
this.pageService.delete(this.pageId).subscribe({
|
||||||
|
next: () => this.router.navigate(['/lore', this.loreId]),
|
||||||
|
error: () => console.error('Erreur lors de la suppression de la page')
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Page } from '../../services/page.model';
|
|||||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||||
import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.component';
|
import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.component';
|
||||||
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
||||||
|
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran de consultation d'une Page (mode lecture seule).
|
* Écran de consultation d'une Page (mode lecture seule).
|
||||||
@@ -51,7 +52,8 @@ export class PageViewComponent implements OnInit, OnDestroy {
|
|||||||
private templateService: TemplateService,
|
private templateService: TemplateService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService,
|
||||||
|
private confirmDialog: ConfirmDialogService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -129,20 +131,31 @@ export class PageViewComponent implements OnInit, OnDestroy {
|
|||||||
deletePage(): void {
|
deletePage(): void {
|
||||||
if (!this.page) return;
|
if (!this.page) return;
|
||||||
const page = this.page;
|
const page = this.page;
|
||||||
if (!confirm(`Supprimer la page "${page.title}" ?\n\nCette action est irréversible.`)) return;
|
this.confirmDialog.confirm({
|
||||||
this.pageService.delete(page.id!).subscribe({
|
title: 'Supprimer la page',
|
||||||
next: () => {
|
message: `Supprimer la page "${page.title}" ?`,
|
||||||
if (page.nodeId) {
|
details: ['Cette action est irréversible.'],
|
||||||
this.router.navigate(['/lore', this.loreId, 'folders', page.nodeId]);
|
confirmLabel: 'Supprimer',
|
||||||
} else {
|
variant: 'danger'
|
||||||
this.router.navigate(['/lore', this.loreId]);
|
}).then(ok => {
|
||||||
}
|
if (!ok) return;
|
||||||
},
|
this.pageService.delete(page.id!).subscribe({
|
||||||
error: () => console.error('Erreur lors de la suppression de la page')
|
next: () => {
|
||||||
|
if (page.nodeId) {
|
||||||
|
this.router.navigate(['/lore', this.loreId, 'folders', page.nodeId]);
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/lore', this.loreId]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => console.error('Erreur lors de la suppression de la page')
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,32 +176,40 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
|
|||||||
defaultNodeId: raw.defaultNodeId,
|
defaultNodeId: raw.defaultNodeId,
|
||||||
fields: this.fields
|
fields: this.fields
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: () => this.navigateBack(),
|
next: (created) => this.navigateBack(created.id ?? null),
|
||||||
error: () => console.error('Erreur lors de la création du template')
|
error: () => console.error('Erreur lors de la création du template')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel(): void {
|
cancel(): void {
|
||||||
this.navigateBack();
|
this.navigateBack(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redirige vers l'écran d'origine en dépilant le premier élément du query-param
|
* Redirige vers l'écran d'origine en dépilant le premier élément du query-param
|
||||||
* `returnTo` (pile de retours séparés par des virgules, ex : `page-create` ou
|
* `returnTo` (pile de retours séparés par des virgules, ex : `page-create` ou
|
||||||
* `template-create,page-create`). Sinon retombe sur la page détail du Lore.
|
* `template-create,page-create`). Sinon retombe sur la page détail du Lore.
|
||||||
|
*
|
||||||
|
* Si `createdTemplateId` est fourni (cas submit), on l'embarque dans le
|
||||||
|
* query-param `selectTemplateId` pour que page-create puisse pre-selectionner
|
||||||
|
* le template fraichement cree.
|
||||||
*/
|
*/
|
||||||
private navigateBack(): void {
|
private navigateBack(createdTemplateId: string | null): void {
|
||||||
const { next, rest } = popReturnTo(this.route.snapshot.queryParamMap.get('returnTo'));
|
const { next, rest } = popReturnTo(this.route.snapshot.queryParamMap.get('returnTo'));
|
||||||
if (next === 'page-create') {
|
if (next === 'page-create') {
|
||||||
this.router.navigate(['/lore', this.loreId, 'pages', 'create'], {
|
const queryParams: Record<string, string> = {};
|
||||||
queryParams: rest ? { returnTo: rest } : {}
|
if (rest) queryParams['returnTo'] = rest;
|
||||||
});
|
if (createdTemplateId) queryParams['selectTemplateId'] = createdTemplateId;
|
||||||
|
this.router.navigate(['/lore', this.loreId, 'pages', 'create'], { queryParams });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.router.navigate(['/lore', this.loreId]);
|
this.router.navigate(['/lore', this.loreId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { forkJoin } from 'rxjs';
|
import { forkJoin, Subject } from 'rxjs';
|
||||||
|
import { switchMap, takeUntil } from 'rxjs/operators';
|
||||||
import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown } from 'lucide-angular';
|
import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown } from 'lucide-angular';
|
||||||
import { LoreService } from '../../services/lore.service';
|
import { LoreService } from '../../services/lore.service';
|
||||||
import { TemplateService } from '../../services/template.service';
|
import { TemplateService } from '../../services/template.service';
|
||||||
@@ -12,6 +13,7 @@ import { PageTitleService } from '../../services/page-title.service';
|
|||||||
import { LoreNode } from '../../services/lore.model';
|
import { LoreNode } from '../../services/lore.model';
|
||||||
import { FieldType, ImageLayout, Template, TemplateField } from '../../services/template.model';
|
import { FieldType, ImageLayout, Template, TemplateField } from '../../services/template.model';
|
||||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||||
|
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran d'édition d'un Template existant.
|
* Écran d'édition d'un Template existant.
|
||||||
@@ -47,6 +49,8 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
|||||||
*/
|
*/
|
||||||
private originalFieldNames = new Set<string>();
|
private originalFieldNames = new Set<string>();
|
||||||
|
|
||||||
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
/** True si le champ est présent depuis le chargement du template. */
|
/** True si le champ est présent depuis le chargement du template. */
|
||||||
isExistingField(field: TemplateField): boolean {
|
isExistingField(field: TemplateField): boolean {
|
||||||
return this.originalFieldNames.has(field.name);
|
return this.originalFieldNames.has(field.name);
|
||||||
@@ -60,7 +64,8 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
|||||||
private templateService: TemplateService,
|
private templateService: TemplateService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService,
|
||||||
|
private confirmDialog: ConfirmDialogService
|
||||||
) {
|
) {
|
||||||
this.form = this.fb.group({
|
this.form = this.fb.group({
|
||||||
name: ['', Validators.required],
|
name: ['', Validators.required],
|
||||||
@@ -70,13 +75,21 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
|
// switchMap pour annuler le chargement precedent si l'utilisateur change
|
||||||
this.templateId = this.route.snapshot.paramMap.get('templateId')!;
|
// de template avant la fin de la requete (Angular reutilise l'instance du
|
||||||
|
// composant entre /templates/T1 et /templates/T2, donc ngOnInit ne refire
|
||||||
forkJoin({
|
// pas et il faut reagir aux changements de params nous-memes).
|
||||||
sidebar: loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService),
|
this.route.paramMap.pipe(
|
||||||
template: this.templateService.getById(this.templateId)
|
switchMap(params => {
|
||||||
}).subscribe(({ sidebar, template }) => {
|
this.loreId = params.get('loreId')!;
|
||||||
|
this.templateId = params.get('templateId')!;
|
||||||
|
return forkJoin({
|
||||||
|
sidebar: loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService),
|
||||||
|
template: this.templateService.getById(this.templateId)
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
takeUntil(this.destroy$)
|
||||||
|
).subscribe(({ sidebar, template }) => {
|
||||||
this.nodes = sidebar.nodes;
|
this.nodes = sidebar.nodes;
|
||||||
this.layoutService.show(buildLoreSidebarConfig(sidebar));
|
this.layoutService.show(buildLoreSidebarConfig(sidebar));
|
||||||
this.hydrate(template);
|
this.hydrate(template);
|
||||||
@@ -162,14 +175,25 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
delete(): void {
|
delete(): void {
|
||||||
if (!confirm(`Supprimer le template "${this.template?.name}" ?`)) return;
|
this.confirmDialog.confirm({
|
||||||
this.templateService.delete(this.templateId).subscribe({
|
title: 'Supprimer le template',
|
||||||
next: () => this.router.navigate(['/lore', this.loreId]),
|
message: `Supprimer le template "${this.template?.name}" ?`,
|
||||||
error: () => console.error('Erreur lors de la suppression du template')
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
|
this.templateService.delete(this.templateId).subscribe({
|
||||||
|
next: () => this.router.navigate(['/lore', this.loreId]),
|
||||||
|
error: () => console.error('Erreur lors de la suppression du template')
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
|
// hide() volontairement retire : la sidebar reste prise en charge par le
|
||||||
|
// composant suivant (sous-route ou detail parent) afin d'eviter qu'elle
|
||||||
|
// disparaisse lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
51
web/src/app/services/campaign-sidebar.service.ts
Normal file
51
web/src/app/services/campaign-sidebar.service.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { forkJoin, Subscription } from 'rxjs';
|
||||||
|
import { CampaignService } from './campaign.service';
|
||||||
|
import { CharacterService } from './character.service';
|
||||||
|
import { NpcService } from './npc.service';
|
||||||
|
import { LayoutService } from './layout.service';
|
||||||
|
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../campaigns/campaign-tree.helper';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service utilitaire qui charge et affiche la sidebar secondaire d'une campagne
|
||||||
|
* (arbre arcs/chapitres/scenes + PJ/PNJ + items globaux).
|
||||||
|
*
|
||||||
|
* Centralise un pattern dupliquait dans 13+ composants (arc-view/edit/create,
|
||||||
|
* chapter-*, scene-*, character-view/edit, npc-view/edit, campaign-detail) :
|
||||||
|
* meme forkJoin de 3 sources + meme config layoutService.show().
|
||||||
|
*
|
||||||
|
* Utilisation :
|
||||||
|
* ```ts
|
||||||
|
* constructor(private campaignSidebar: CampaignSidebarService) {}
|
||||||
|
* ngOnInit() { this.campaignSidebar.show(this.campaignId); }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class CampaignSidebarService {
|
||||||
|
constructor(
|
||||||
|
private campaignService: CampaignService,
|
||||||
|
private characterService: CharacterService,
|
||||||
|
private npcService: NpcService,
|
||||||
|
private layoutService: LayoutService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge les donnees et configure la sidebar secondaire pour la campagne.
|
||||||
|
* Renvoie la Subscription pour permettre au caller de l'annuler s'il le
|
||||||
|
* souhaite (rarement utile vu que les requetes terminent vite).
|
||||||
|
*/
|
||||||
|
show(campaignId: string): Subscription {
|
||||||
|
return forkJoin({
|
||||||
|
campaign: this.campaignService.getCampaignById(campaignId),
|
||||||
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
|
treeData: loadCampaignTreeData(
|
||||||
|
this.campaignService,
|
||||||
|
campaignId,
|
||||||
|
this.characterService,
|
||||||
|
this.npcService
|
||||||
|
)
|
||||||
|
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||||
|
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, campaignId));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ export interface Character {
|
|||||||
headerImageId?: string | null;
|
headerImageId?: string | null;
|
||||||
values?: Record<string, string>;
|
values?: Record<string, string>;
|
||||||
imageValues?: Record<string, string[]>;
|
imageValues?: Record<string, string[]>;
|
||||||
|
/** Champs KEY_VALUE_LIST : fieldName -> label -> value. */
|
||||||
|
keyValueValues?: Record<string, Record<string, string>>;
|
||||||
campaignId: string;
|
campaignId: string;
|
||||||
order?: number;
|
order?: number;
|
||||||
}
|
}
|
||||||
@@ -23,5 +25,6 @@ export interface CharacterCreate {
|
|||||||
headerImageId?: string | null;
|
headerImageId?: string | null;
|
||||||
values?: Record<string, string>;
|
values?: Record<string, string>;
|
||||||
imageValues?: Record<string, string[]>;
|
imageValues?: Record<string, string[]>;
|
||||||
|
keyValueValues?: Record<string, Record<string, string>>;
|
||||||
campaignId: string;
|
campaignId: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,24 @@ export interface LicenseStatusDTO {
|
|||||||
betaChannelEnabled: boolean;
|
betaChannelEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Etat du canal courant + dernier resultat de bascule (cf. ChannelStatusDTO cote backend).
|
||||||
|
*/
|
||||||
|
export type ChannelName = 'stable' | 'beta';
|
||||||
|
export type SwitchStatus = 'IN_PROGRESS' | 'SUCCESS' | 'ERROR';
|
||||||
|
|
||||||
|
export interface ChannelStatusDTO {
|
||||||
|
currentChannel: ChannelName;
|
||||||
|
switcherAvailable: boolean;
|
||||||
|
lastSwitch: {
|
||||||
|
id: string;
|
||||||
|
status: SwitchStatus;
|
||||||
|
channel: ChannelName;
|
||||||
|
message: string;
|
||||||
|
completedAt: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reflet de UpdateCheckService.BetaStatus.
|
* Reflet de UpdateCheckService.BetaStatus.
|
||||||
*/
|
*/
|
||||||
@@ -91,4 +109,23 @@ export class LicenseService {
|
|||||||
catchError(() => of(null))
|
catchError(() => of(null))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Etat du canal courant et dernier resultat de switch (pour polling UI). */
|
||||||
|
getChannelStatus(): Observable<ChannelStatusDTO | null> {
|
||||||
|
return this.http.get<ChannelStatusDTO>(`${this.apiUrl}/channel`, this.authOptions).pipe(
|
||||||
|
catchError(() => of(null))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declenche un switch de canal. 202 + { id, channel } si accepte,
|
||||||
|
* sinon erreur (403 = pas de licence, 503 = sidecar indispo, etc.).
|
||||||
|
*/
|
||||||
|
switchChannel(channel: ChannelName): Observable<{ id: string; channel: ChannelName } | { error: string }> {
|
||||||
|
return this.http.post<{ id: string; channel: ChannelName }>(
|
||||||
|
`${this.apiUrl}/channel/switch`, { channel }, this.authOptions
|
||||||
|
).pipe(
|
||||||
|
catchError((err) => of({ error: err?.error?.error ?? 'Echec du switch de canal' }))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface Npc {
|
|||||||
headerImageId?: string | null;
|
headerImageId?: string | null;
|
||||||
values?: Record<string, string>;
|
values?: Record<string, string>;
|
||||||
imageValues?: Record<string, string[]>;
|
imageValues?: Record<string, string[]>;
|
||||||
|
keyValueValues?: Record<string, Record<string, string>>;
|
||||||
campaignId: string;
|
campaignId: string;
|
||||||
order?: number;
|
order?: number;
|
||||||
}
|
}
|
||||||
@@ -19,5 +20,6 @@ export interface NpcCreate {
|
|||||||
headerImageId?: string | null;
|
headerImageId?: string | null;
|
||||||
values?: Record<string, string>;
|
values?: Record<string, string>;
|
||||||
imageValues?: Record<string, string[]>;
|
imageValues?: Record<string, string[]>;
|
||||||
|
keyValueValues?: Record<string, Record<string, string>>;
|
||||||
campaignId: string;
|
campaignId: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Type d'un champ de Template. Miroir de com.loremind.domain.shared.template.FieldType.
|
* Type d'un champ de Template. Miroir de com.loremind.domain.shared.template.FieldType.
|
||||||
* - 'TEXT' : champ textuel libre (rendu en textarea)
|
* - 'TEXT' : champ textuel libre (rendu en textarea)
|
||||||
* - 'IMAGE' : galerie d'images (rendu en app-image-gallery)
|
* - 'IMAGE' : galerie d'images (rendu en app-image-gallery)
|
||||||
* - 'NUMBER' : valeur numerique (rendu en input number)
|
* - 'NUMBER' : valeur numerique (rendu en input number)
|
||||||
|
* - 'KEY_VALUE_LIST' : liste de paires {label, value} avec labels figes au template
|
||||||
*/
|
*/
|
||||||
export type FieldType = 'TEXT' | 'IMAGE' | 'NUMBER';
|
export type FieldType = 'TEXT' | 'IMAGE' | 'NUMBER' | 'KEY_VALUE_LIST';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Variante de rendu pour un champ IMAGE. Miroir de
|
* Variante de rendu pour un champ IMAGE. Miroir de
|
||||||
@@ -27,6 +28,8 @@ export interface TemplateField {
|
|||||||
type: FieldType;
|
type: FieldType;
|
||||||
/** Uniquement pour type='IMAGE'. Absent/null = 'GALLERY'. */
|
/** Uniquement pour type='IMAGE'. Absent/null = 'GALLERY'. */
|
||||||
layout?: ImageLayout | null;
|
layout?: ImageLayout | null;
|
||||||
|
/** Labels predefinis pour KEY_VALUE_LIST (ordre significatif). */
|
||||||
|
labels?: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Template {
|
export interface Template {
|
||||||
|
|||||||
@@ -252,22 +252,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div *ngIf="updateStatus?.anyUnknown && !updateStatus?.updateAvailable" class="alert alert-warn">
|
<div *ngIf="updateStatus?.anyUnknown && !updateStatus?.updateAvailable" class="alert alert-warn">
|
||||||
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
|
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
|
||||||
<span>Verification impossible pour certaines images — voir details ci-dessous.</span>
|
<span>Verification impossible (baseline absente ou registry injoignable).</span>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="!updateStatus?.updateAvailable && !updateStatus?.anyUnknown" class="hint">
|
<div *ngIf="!updateStatus?.updateAvailable && !updateStatus?.anyUnknown" class="hint">
|
||||||
Tout est a jour (verifie le {{ updateStatus?.checkedAt | date:'short' }}).
|
Tout est a jour (verifie le {{ updateStatus?.checkedAt | date:'short' }}).
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="update-images" *ngIf="updateStatus?.images?.length">
|
|
||||||
<li *ngFor="let img of updateStatus?.images">
|
|
||||||
<strong>{{ img.image }}</strong>
|
|
||||||
<span *ngIf="img.status === 'UPDATE_AVAILABLE'" class="badge-update">MAJ dispo</span>
|
|
||||||
<span *ngIf="img.status === 'UP_TO_DATE'" class="badge-ok">a jour</span>
|
|
||||||
<span *ngIf="img.status === 'UNKNOWN'" class="badge-warn"
|
|
||||||
title="Impossible de comparer (baseline absente ou registry injoignable)">verification impossible</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="form-row" *ngIf="updateStatus?.updateAvailable">
|
<div class="form-row" *ngIf="updateStatus?.updateAvailable">
|
||||||
<button type="button" class="btn-primary" (click)="applyUpdate()" [disabled]="updateApplying">
|
<button type="button" class="btn-primary" (click)="applyUpdate()" [disabled]="updateApplying">
|
||||||
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
|
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
|
||||||
@@ -333,7 +323,7 @@
|
|||||||
<ng-container *ngIf="licenseStatus && licenseStatus.status !== 'NONE'">
|
<ng-container *ngIf="licenseStatus && licenseStatus.status !== 'NONE'">
|
||||||
<div *ngIf="licenseStatus.status === 'VALID'" class="alert alert-success">
|
<div *ngIf="licenseStatus.status === 'VALID'" class="alert alert-success">
|
||||||
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
|
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
|
||||||
<span>Compte Patreon connecte. Tier {{ licenseStatus.tierId }} actif.</span>
|
<span>Compte Patreon connecte. Tier {{ tierLabel(licenseStatus.tierId) }} actif.</span>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="licenseStatus.status === 'GRACE'" class="alert alert-warn">
|
<div *ngIf="licenseStatus.status === 'GRACE'" class="alert alert-warn">
|
||||||
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
||||||
@@ -354,7 +344,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="license-info">
|
<ul class="license-info">
|
||||||
<li *ngIf="licenseStatus.tierId"><strong>Tier :</strong> {{ licenseStatus.tierId }}</li>
|
<li *ngIf="licenseStatus.tierId"><strong>Tier :</strong> {{ tierLabel(licenseStatus.tierId) }}</li>
|
||||||
<li *ngIf="licenseStatus.expiresAt">
|
<li *ngIf="licenseStatus.expiresAt">
|
||||||
<strong>Validite :</strong>
|
<strong>Validite :</strong>
|
||||||
jusqu'au {{ formatDate(licenseStatus.expiresAt) }}
|
jusqu'au {{ formatDate(licenseStatus.expiresAt) }}
|
||||||
@@ -400,23 +390,68 @@
|
|||||||
Indisponible : {{ betaStatus.disabledReason }}
|
Indisponible : {{ betaStatus.disabledReason }}
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="!betaChecking && betaStatus?.enabled">
|
<div *ngIf="!betaChecking && betaStatus?.enabled">
|
||||||
<div *ngIf="betaStatus?.updateAvailable" class="alert alert-success">
|
<div *ngIf="betaStatus?.anyUnknown" class="alert alert-warn">
|
||||||
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
|
|
||||||
<span>Une version beta est disponible. Pour l'installer, modifie ton fichier <code>.env</code> :
|
|
||||||
<code>IMAGE_NAMESPACE=igmlcreation/loremind-beta-</code> puis
|
|
||||||
<code>docker compose pull && docker compose up -d</code>.</span>
|
|
||||||
</div>
|
|
||||||
<div *ngIf="betaStatus?.anyUnknown && !betaStatus?.updateAvailable" class="alert alert-warn">
|
|
||||||
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
||||||
<span>Verification beta impossible pour certaines images.</span>
|
<span>Verification beta impossible (registry beta injoignable ou baseline absente).</span>
|
||||||
</div>
|
</div>
|
||||||
<ul class="update-images" *ngIf="betaStatus?.images?.length">
|
</div>
|
||||||
<li *ngFor="let img of betaStatus?.images">
|
</div>
|
||||||
<strong>{{ img.image }}</strong>
|
|
||||||
<span *ngIf="img.status === 'UPDATE_AVAILABLE'" class="badge-update">version dispo</span>
|
<!-- Bascule de canal (stable <-> beta) via sidecar switcher -->
|
||||||
<span *ngIf="img.status === 'UNKNOWN'" class="badge-warn">verification impossible</span>
|
<div class="channel-switch" *ngIf="channelStatus">
|
||||||
</li>
|
<div class="channel-current">
|
||||||
</ul>
|
<span class="channel-label">Canal actuel :</span>
|
||||||
|
<span class="channel-badge"
|
||||||
|
[class.channel-stable]="channelStatus.currentChannel === 'stable'"
|
||||||
|
[class.channel-beta]="channelStatus.currentChannel === 'beta'">
|
||||||
|
{{ channelStatus.currentChannel === 'beta' ? 'Bêta' : 'Stable' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidecar dispo : boutons d'action -->
|
||||||
|
<ng-container *ngIf="channelStatus.switcherAvailable">
|
||||||
|
<!-- On stable -> proposer passage beta (uniquement si licence active) -->
|
||||||
|
<button *ngIf="channelStatus.currentChannel === 'stable'"
|
||||||
|
type="button" class="btn-primary"
|
||||||
|
[disabled]="switchInFlight"
|
||||||
|
(click)="requestChannelSwitch('beta')">
|
||||||
|
<lucide-icon [img]="Download" [size]="14"></lucide-icon>
|
||||||
|
<span>{{ switchInFlight ? 'Bascule en cours...' : 'Passer sur le canal beta' }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- On beta -> proposer retour stable -->
|
||||||
|
<button *ngIf="channelStatus.currentChannel === 'beta'"
|
||||||
|
type="button" class="btn-secondary"
|
||||||
|
[disabled]="switchInFlight"
|
||||||
|
(click)="requestChannelSwitch('stable')">
|
||||||
|
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||||
|
<span>{{ switchInFlight ? 'Bascule en cours...' : 'Repasser sur le canal stable' }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Switch en cours : on prévient que la page va se rendre injoignable -->
|
||||||
|
<div *ngIf="switchInFlight" class="alert alert-warn">
|
||||||
|
<lucide-icon [img]="RefreshCw" [size]="16"></lucide-icon>
|
||||||
|
<span>Bascule en cours. L'application va etre indisponible 10 a 30 secondes — la page se rechargera automatiquement quand le nouveau Core sera pret.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Erreur eventuelle remontee par le sidecar -->
|
||||||
|
<div *ngIf="switchError && !switchInFlight" class="alert alert-error">
|
||||||
|
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
||||||
|
<span>{{ switchError }}</span>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Sidecar PAS dispo : fallback instructions manuelles (vieilles installs) -->
|
||||||
|
<div *ngIf="!channelStatus.switcherAvailable" class="alert alert-warn">
|
||||||
|
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
||||||
|
<span>
|
||||||
|
Le sidecar de bascule n'est pas installe. Pour beneficier du switch
|
||||||
|
automatique, recupere le dernier <code>docker-compose.yml</code> du repo
|
||||||
|
et fais <code>docker compose pull && docker compose up -d</code> une
|
||||||
|
fois. Sinon, bascule manuellement en editant <code>IMAGE_NAMESPACE</code>
|
||||||
|
dans ton <code>.env</code> (<code>igmlcreation/loremind-</code> pour stable,
|
||||||
|
<code>igmlcreation/loremind-beta-</code> pour beta).
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|||||||
@@ -322,32 +322,42 @@
|
|||||||
accent-color: #6c63ff;
|
accent-color: #6c63ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-images {
|
.channel-switch {
|
||||||
list-style: none;
|
margin-top: 1rem;
|
||||||
padding: 0;
|
padding: 1rem;
|
||||||
margin: 0.75rem 0;
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.4rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
.update-images li {
|
.channel-current {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.6rem;
|
gap: 0.5rem;
|
||||||
padding: 0.4rem 0.6rem;
|
font-size: 0.9rem;
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
|
.channel-label { color: #9ca3af; }
|
||||||
|
}
|
||||||
|
.channel-badge {
|
||||||
|
padding: 0.2rem 0.65rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.875rem;
|
font-size: 0.8rem;
|
||||||
}
|
|
||||||
.badge-update {
|
|
||||||
margin-left: auto;
|
|
||||||
background: #6c63ff;
|
|
||||||
color: white;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 0.15rem 0.5rem;
|
text-transform: uppercase;
|
||||||
border-radius: 3px;
|
letter-spacing: 0.5px;
|
||||||
|
|
||||||
|
&.channel-stable {
|
||||||
|
background: rgba(76, 175, 80, 0.2);
|
||||||
|
color: #81c784;
|
||||||
|
}
|
||||||
|
&.channel-beta {
|
||||||
|
background: rgba(108, 99, 255, 0.2);
|
||||||
|
color: #a39bff;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-ok {
|
.badge-ok {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
background: rgba(76, 175, 80, 0.2);
|
background: rgba(76, 175, 80, 0.2);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user