Compare commits
7 Commits
7c74c12f3e
...
v0.8.7-bet
| Author | SHA1 | Date | |
|---|---|---|---|
| 586ddceff6 | |||
| 4b9b7f0995 | |||
| 3d73b1e6a7 | |||
| 759e47fc1f | |||
| f71bf3fcad | |||
| 0cd99dfb32 | |||
| f24ef0891e |
@@ -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
|
||||||
|
|||||||
@@ -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.8.3",
|
version="0.8.7-beta",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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.3</version>
|
<version>0.8.7-beta</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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,51 @@ 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
|
||||||
|
# Volume partage avec le Core + Watchtower : contient config.json avec
|
||||||
|
# les creds GHCR (ecrits par le Core a partir du token Patreon).
|
||||||
|
# Indispensable pour pull les images privees du canal beta.
|
||||||
|
- docker-config:/shared/docker
|
||||||
|
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}
|
||||||
|
# Indique au CLI Docker du switcher ou trouver config.json (auth GHCR
|
||||||
|
# pour les images privees beta). Meme mecanisme que sur Watchtower.
|
||||||
|
DOCKER_CONFIG: /shared/docker
|
||||||
|
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 +266,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:
|
||||||
|
|||||||
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.
|
||||||
123
switcher/switch.sh
Normal file
123
switcher/switch.sh
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
#!/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, tag) -------------------------------------
|
||||||
|
# Le slash final du namespace est important : concatene avec le suffixe image
|
||||||
|
# (core/brain/web) dans le docker-compose.yml.
|
||||||
|
# Cote tag : le workflow CI pousse :latest pour le canal stable, :beta pour
|
||||||
|
# le canal beta. Le switcher doit donc forcer ces deux variables ensemble.
|
||||||
|
case "${CHANNEL}" in
|
||||||
|
stable)
|
||||||
|
NAMESPACE="igmlcreation/loremind-"
|
||||||
|
TAG="latest"
|
||||||
|
;;
|
||||||
|
beta)
|
||||||
|
NAMESPACE="igmlcreation/loremind-beta-"
|
||||||
|
TAG="beta"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Helper : met a jour (ou ajoute) une variable key=value dans le .env.
|
||||||
|
update_env_var() {
|
||||||
|
local key="$1"
|
||||||
|
local value="$2"
|
||||||
|
if grep -q "^${key}=" "${ENV_FILE}"; then
|
||||||
|
# Sur Alpine, sed -i sans backup. Le pattern '/' dans la valeur impose
|
||||||
|
# un delimiter alternatif (|).
|
||||||
|
sed -i "s|^${key}=.*|${key}=${value}|" "${ENV_FILE}"
|
||||||
|
else
|
||||||
|
# Ligne absente → on l'ajoute en fin de fichier la premiere fois.
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "# Ajoute automatiquement par le switcher de canal LoreMind."
|
||||||
|
echo "${key}=${value}"
|
||||||
|
} >> "${ENV_FILE}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Etape 1 : sed le .env -------------------------------------------------
|
||||||
|
echo "→ Mise a jour de IMAGE_NAMESPACE + TAG dans .env (canal: ${CHANNEL})"
|
||||||
|
update_env_var "IMAGE_NAMESPACE" "${NAMESPACE}"
|
||||||
|
update_env_var "TAG" "${TAG}"
|
||||||
|
|
||||||
|
# --- 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}*:${TAG}."
|
||||||
88
switcher/watch.sh
Normal file
88
switcher/watch.sh
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
#!/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}"
|
||||||
|
# Log la sortie sur plusieurs lignes pour faciliter le debug
|
||||||
|
# (ce qu'on voit en docker logs).
|
||||||
|
while IFS= read -r line; do log " | ${line}"; done <<< "${output}"
|
||||||
|
write_result "success" "${channel}" "${output}" "${id}"
|
||||||
|
else
|
||||||
|
rc=$?
|
||||||
|
log "Switch FAILED for id=${id} channel=${channel} rc=${rc}"
|
||||||
|
while IFS= read -r line; do log " | ${line}"; done <<< "${output}"
|
||||||
|
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.3",
|
"version": "0.8.7-beta",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.8.3",
|
"version": "0.8.7-beta",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^17.0.0",
|
"@angular/animations": "^17.0.0",
|
||||||
"@angular/common": "^17.0.0",
|
"@angular/common": "^17.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.8.3",
|
"version": "0.8.7-beta",
|
||||||
"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,11 +171,19 @@ 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({
|
||||||
|
title: 'Supprimer l\'arc',
|
||||||
|
message: `Supprimer l'arc "${this.arc?.name}" ?`,
|
||||||
|
details: ['Cette action est irréversible.'],
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
this.campaignService.deleteArc(this.arcId).subscribe({
|
this.campaignService.deleteArc(this.arcId).subscribe({
|
||||||
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
||||||
error: () => console.error('Erreur lors de la suppression')
|
error: () => console.error('Erreur lors de la suppression')
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel(): void {
|
cancel(): void {
|
||||||
@@ -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,25 +111,34 @@ 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({
|
||||||
|
title: 'Supprimer l\'arc',
|
||||||
|
message: `Supprimer l'arc "${arc.name}" ?`,
|
||||||
|
details,
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
this.campaignService.deleteArc(arc.id!).subscribe({
|
this.campaignService.deleteArc(arc.id!).subscribe({
|
||||||
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
||||||
error: () => console.error('Erreur lors de la suppression de l\'arc')
|
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')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
||||||
|
|
||||||
|
<!-- 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">
|
<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,19 +381,23 @@ 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({
|
||||||
|
title: 'Supprimer la campagne ?',
|
||||||
|
message: `Supprimer definitivement la campagne "${campaign.name}" ?`,
|
||||||
|
details,
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
this.campaignService.deleteCampaign(campaign.id!).subscribe({
|
this.campaignService.deleteCampaign(campaign.id!).subscribe({
|
||||||
next: () => this.router.navigate(['/campaigns']),
|
next: () => this.router.navigate(['/campaigns']),
|
||||||
error: () => console.error('Erreur lors de la suppression de la campagne')
|
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,11 +157,19 @@ 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({
|
||||||
|
title: 'Supprimer le chapitre',
|
||||||
|
message: `Supprimer le chapitre "${this.chapter?.name}" ?`,
|
||||||
|
details: ['Cette action est irréversible.'],
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
this.campaignService.deleteChapter(this.chapterId).subscribe({
|
this.campaignService.deleteChapter(this.chapterId).subscribe({
|
||||||
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
||||||
error: () => console.error('Erreur lors de la suppression')
|
error: () => console.error('Erreur lors de la suppression')
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel(): void {
|
cancel(): void {
|
||||||
@@ -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,25 +117,34 @@ 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({
|
||||||
|
title: 'Supprimer le chapitre',
|
||||||
|
message: `Supprimer le chapitre "${chapter.name}" ?`,
|
||||||
|
details,
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
this.campaignService.deleteChapter(chapter.id!).subscribe({
|
this.campaignService.deleteChapter(chapter.id!).subscribe({
|
||||||
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]),
|
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]),
|
||||||
error: () => console.error('Erreur lors de la suppression du chapitre')
|
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')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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"
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -62,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 {
|
||||||
@@ -72,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) {
|
||||||
@@ -106,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 = {
|
||||||
@@ -117,22 +123,37 @@ export class CharacterEditComponent implements OnInit {
|
|||||||
keyValueValues: this.keyValueValues,
|
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({
|
||||||
|
title: 'Supprimer la fiche ?',
|
||||||
|
message: `Supprimer la fiche de "${this.name}" ?`,
|
||||||
|
details: ['Cette action est irreversible.'],
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok || !this.characterId) return;
|
||||||
this.service.delete(this.characterId).subscribe({
|
this.service.delete(this.characterId).subscribe({
|
||||||
next: () => this.back(),
|
next: () => this.back(),
|
||||||
error: () => console.error('Erreur suppression Character')
|
error: () => console.error('Erreur suppression Character')
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
back(): void {
|
back(): void {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -57,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 {
|
||||||
@@ -67,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) {
|
||||||
@@ -101,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 = {
|
||||||
@@ -112,22 +118,37 @@ export class NpcEditComponent implements OnInit {
|
|||||||
keyValueValues: this.keyValueValues,
|
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({
|
||||||
|
title: 'Supprimer la fiche ?',
|
||||||
|
message: `Supprimer la fiche de "${this.name}" ?`,
|
||||||
|
details: ['Cette action est irreversible.'],
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok || !this.npcId) return;
|
||||||
this.service.delete(this.npcId).subscribe({
|
this.service.delete(this.npcId).subscribe({
|
||||||
next: () => this.back(),
|
next: () => this.back(),
|
||||||
error: () => console.error('Erreur suppression Npc')
|
error: () => console.error('Erreur suppression Npc')
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
back(): void {
|
back(): void {
|
||||||
|
|||||||
@@ -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,11 +188,19 @@ 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({
|
||||||
|
title: 'Supprimer la scène',
|
||||||
|
message: `Supprimer la scène "${this.scene?.name}" ?`,
|
||||||
|
details: ['Cette action est irréversible.'],
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
this.campaignService.deleteScene(this.sceneId).subscribe({
|
this.campaignService.deleteScene(this.sceneId).subscribe({
|
||||||
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
||||||
error: () => console.error('Erreur lors de la suppression')
|
error: () => console.error('Erreur lors de la suppression')
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel(): void {
|
cancel(): void {
|
||||||
@@ -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({
|
||||||
|
title: 'Supprimer la scène',
|
||||||
|
message: `Supprimer la scène "${scene.name}" ?`,
|
||||||
|
details: ['Cette action est irréversible.'],
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
this.campaignService.deleteScene(scene.id!).subscribe({
|
this.campaignService.deleteScene(scene.id!).subscribe({
|
||||||
next: () => this.router.navigate([
|
next: () => this.router.navigate([
|
||||||
'/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId
|
'/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId
|
||||||
]),
|
]),
|
||||||
error: () => console.error('Erreur lors de la suppression de la scène')
|
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({
|
||||||
|
title: 'Supprimer le système',
|
||||||
|
message: `Supprimer le système "${system.name}" ?`,
|
||||||
|
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({
|
this.gameSystemService.delete(system.id).subscribe({
|
||||||
next: () => this.load(),
|
next: () => this.load(),
|
||||||
error: () => console.error('Erreur suppression GameSystem')
|
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,15 +150,20 @@ 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({
|
||||||
|
title: 'Supprimer le dossier',
|
||||||
|
message: `Supprimer le dossier "${node.name}" ?`,
|
||||||
|
details,
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
this.loreService.deleteLoreNode(this.folderId).subscribe({
|
this.loreService.deleteLoreNode(this.folderId).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
// Remonte au dossier parent si présent, sinon au Lore.
|
// Remonte au dossier parent si présent, sinon au Lore.
|
||||||
@@ -168,12 +175,16 @@ export class FolderViewComponent implements OnInit, OnDestroy {
|
|||||||
},
|
},
|
||||||
error: () => console.error('Erreur lors de la suppression du dossier')
|
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')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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,26 +127,31 @@ 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({
|
||||||
|
title: 'Supprimer le Lore',
|
||||||
|
message: `Supprimer définitivement le Lore "${lore.name}" ?`,
|
||||||
|
details,
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
this.loreService.deleteLore(lore.id!).subscribe({
|
this.loreService.deleteLore(lore.id!).subscribe({
|
||||||
next: () => this.router.navigate(['/lore']),
|
next: () => this.router.navigate(['/lore']),
|
||||||
error: () => console.error('Erreur lors de la suppression du 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({
|
||||||
|
title: 'Supprimer la page',
|
||||||
|
message: `Supprimer la page "${this.page.title}" ?`,
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok || !this.page) return;
|
||||||
this.pageService.delete(this.pageId).subscribe({
|
this.pageService.delete(this.pageId).subscribe({
|
||||||
next: () => this.router.navigate(['/lore', this.loreId]),
|
next: () => this.router.navigate(['/lore', this.loreId]),
|
||||||
error: () => console.error('Erreur lors de la suppression de la page')
|
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,7 +131,14 @@ 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({
|
||||||
|
title: 'Supprimer la page',
|
||||||
|
message: `Supprimer la page "${page.title}" ?`,
|
||||||
|
details: ['Cette action est irréversible.'],
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
this.pageService.delete(page.id!).subscribe({
|
this.pageService.delete(page.id!).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
if (page.nodeId) {
|
if (page.nodeId) {
|
||||||
@@ -140,9 +149,13 @@ export class PageViewComponent implements OnInit, OnDestroy {
|
|||||||
},
|
},
|
||||||
error: () => console.error('Erreur lors de la suppression de la page')
|
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).
|
||||||
|
this.route.paramMap.pipe(
|
||||||
|
switchMap(params => {
|
||||||
|
this.loreId = params.get('loreId')!;
|
||||||
|
this.templateId = params.get('templateId')!;
|
||||||
|
return forkJoin({
|
||||||
sidebar: loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService),
|
sidebar: loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService),
|
||||||
template: this.templateService.getById(this.templateId)
|
template: this.templateService.getById(this.templateId)
|
||||||
}).subscribe(({ sidebar, template }) => {
|
});
|
||||||
|
}),
|
||||||
|
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({
|
||||||
|
title: 'Supprimer le template',
|
||||||
|
message: `Supprimer le template "${this.template?.name}" ?`,
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
this.templateService.delete(this.templateId).subscribe({
|
this.templateService.delete(this.templateId).subscribe({
|
||||||
next: () => this.router.navigate(['/lore', this.loreId]),
|
next: () => this.router.navigate(['/lore', this.loreId]),
|
||||||
error: () => console.error('Erreur lors de la suppression du template')
|
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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' }))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { interval, switchMap, Subscription } from 'rxjs';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Download, Trash2, Plus, X, Heart, Link2, Unlink } from 'lucide-angular';
|
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Download, Trash2, Plus, X, Heart, Link2, Unlink } from 'lucide-angular';
|
||||||
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OllamaPullEvent } from '../services/settings.service';
|
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OllamaPullEvent } from '../services/settings.service';
|
||||||
import { Subscription } from 'rxjs';
|
|
||||||
import { UpdatesService, UpdateStatus } from '../services/updates.service';
|
import { UpdatesService, UpdateStatus } from '../services/updates.service';
|
||||||
import { ConfigService } from '../services/config.service';
|
import { ConfigService } from '../services/config.service';
|
||||||
import { LicenseService, LicenseStatusDTO, BetaStatusDTO } from '../services/license.service';
|
import { LicenseService, LicenseStatusDTO, BetaStatusDTO, ChannelStatusDTO, ChannelName } from '../services/license.service';
|
||||||
|
import { ConfirmDialogService } from '../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ecran de parametrage du LLM utilise par le Brain.
|
* Ecran de parametrage du LLM utilise par le Brain.
|
||||||
@@ -27,7 +28,7 @@ import { LicenseService, LicenseStatusDTO, BetaStatusDTO } from '../services/lic
|
|||||||
templateUrl: './settings.component.html',
|
templateUrl: './settings.component.html',
|
||||||
styleUrls: ['./settings.component.scss']
|
styleUrls: ['./settings.component.scss']
|
||||||
})
|
})
|
||||||
export class SettingsComponent implements OnInit {
|
export class SettingsComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
readonly ArrowLeft = ArrowLeft;
|
readonly ArrowLeft = ArrowLeft;
|
||||||
readonly RefreshCw = RefreshCw;
|
readonly RefreshCw = RefreshCw;
|
||||||
@@ -52,6 +53,17 @@ export class SettingsComponent implements OnInit {
|
|||||||
betaStatus: BetaStatusDTO | null = null;
|
betaStatus: BetaStatusDTO | null = null;
|
||||||
betaChecking = false;
|
betaChecking = false;
|
||||||
|
|
||||||
|
// --- Bascule de canal stable <-> beta via sidecar switcher ---
|
||||||
|
channelStatus: ChannelStatusDTO | null = null;
|
||||||
|
/** True pendant le polling apres clic. Bloque les boutons. */
|
||||||
|
switchInFlight = false;
|
||||||
|
/** ID de la commande de switch en cours, pour ignorer les vieux resultats. */
|
||||||
|
private switchCommandId: string | null = null;
|
||||||
|
/** Subscription du polling pour pouvoir l'arreter. */
|
||||||
|
private switchPollSub: Subscription | null = null;
|
||||||
|
/** Erreur affichee si le switch a echoue. */
|
||||||
|
switchError = '';
|
||||||
|
|
||||||
// --- Pull / delete de modeles Ollama ---
|
// --- Pull / delete de modeles Ollama ---
|
||||||
/** Dialog d'ajout de modele ouvert/ferme. */
|
/** Dialog d'ajout de modele ouvert/ferme. */
|
||||||
pullDialogOpen = false;
|
pullDialogOpen = false;
|
||||||
@@ -120,7 +132,8 @@ export class SettingsComponent implements OnInit {
|
|||||||
private router: Router,
|
private router: Router,
|
||||||
private updatesService: UpdatesService,
|
private updatesService: UpdatesService,
|
||||||
public config: ConfigService,
|
public config: ConfigService,
|
||||||
private licenseService: LicenseService
|
private licenseService: LicenseService,
|
||||||
|
private confirmDialog: ConfirmDialogService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -129,6 +142,7 @@ export class SettingsComponent implements OnInit {
|
|||||||
this.checkUpdates();
|
this.checkUpdates();
|
||||||
}
|
}
|
||||||
this.loadLicense();
|
this.loadLicense();
|
||||||
|
this.loadChannelStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Licence Patreon ---------------------------------------------------
|
// --- Licence Patreon ---------------------------------------------------
|
||||||
@@ -197,13 +211,21 @@ export class SettingsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
disconnectPatreon(): void {
|
disconnectPatreon(): void {
|
||||||
if (!confirm('Deconnecter ton compte Patreon ? Tu perdras l\'acces au canal beta.')) return;
|
this.confirmDialog.confirm({
|
||||||
|
title: 'Deconnecter Patreon',
|
||||||
|
message: 'Deconnecter ton compte Patreon ?',
|
||||||
|
details: ['Tu perdras l\'acces au canal beta.'],
|
||||||
|
confirmLabel: 'Deconnecter',
|
||||||
|
variant: 'warning'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
this.licenseService.disconnect().subscribe(() => {
|
this.licenseService.disconnect().subscribe(() => {
|
||||||
this.licenseStatus = null;
|
this.licenseStatus = null;
|
||||||
this.betaStatus = null;
|
this.betaStatus = null;
|
||||||
this.successMessage = 'Compte Patreon deconnecte.';
|
this.successMessage = 'Compte Patreon deconnecte.';
|
||||||
this.loadLicense();
|
this.loadLicense();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleBetaChannel(enabled: boolean): void {
|
toggleBetaChannel(enabled: boolean): void {
|
||||||
@@ -227,6 +249,126 @@ export class SettingsComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Bascule de canal stable <-> beta --------------------------------------
|
||||||
|
|
||||||
|
loadChannelStatus(): void {
|
||||||
|
this.licenseService.getChannelStatus().subscribe({
|
||||||
|
next: (s) => {
|
||||||
|
this.channelStatus = s;
|
||||||
|
// Si on revient sur l'ecran apres un reload (post-switch reussi),
|
||||||
|
// on affiche le dernier resultat eventuel jusqu'a interaction utilisateur.
|
||||||
|
},
|
||||||
|
error: () => { this.channelStatus = null; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declenche un switch de canal. La sequence cote UI :
|
||||||
|
* 1. Confirm modal (action destructrice : recreate des containers)
|
||||||
|
* 2. POST /api/license/channel/switch -> 202 avec l'ID de la commande
|
||||||
|
* 3. Polling /api/license/channel toutes les 2s jusqu'a status != IN_PROGRESS
|
||||||
|
* 4. Si SUCCESS : la page va se rendre injoignable (Core recree). On affiche
|
||||||
|
* "Recharge la page dans quelques secondes" et on essaie de poll quand
|
||||||
|
* meme — au retour de Core, on detectera SUCCESS et on rechargera auto.
|
||||||
|
* 5. Si ERROR : on affiche le message d'erreur et on debloque les boutons.
|
||||||
|
*/
|
||||||
|
requestChannelSwitch(target: ChannelName): void {
|
||||||
|
const confirmMessage = target === 'beta'
|
||||||
|
? 'Basculer LoreMind sur le canal beta ? Les containers core/brain/web vont etre recrees avec les images beta. L\'application sera indisponible 10-30 secondes.'
|
||||||
|
: 'Repasser LoreMind sur le canal stable ? Les containers core/brain/web vont etre recrees avec les images stables. L\'application sera indisponible 10-30 secondes.';
|
||||||
|
|
||||||
|
this.confirmDialog.confirm({
|
||||||
|
title: target === 'beta' ? 'Passer en beta ?' : 'Repasser en stable ?',
|
||||||
|
message: confirmMessage,
|
||||||
|
details: [
|
||||||
|
'Les donnees (DB, images) sont preservees.',
|
||||||
|
'Tu pourras refaire le chemin inverse a tout moment depuis cet ecran.'
|
||||||
|
],
|
||||||
|
confirmLabel: target === 'beta' ? 'Passer en beta' : 'Repasser en stable',
|
||||||
|
variant: 'warning'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
|
this.doChannelSwitch(target);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private doChannelSwitch(target: ChannelName): void {
|
||||||
|
this.switchInFlight = true;
|
||||||
|
this.switchError = '';
|
||||||
|
this.licenseService.switchChannel(target).subscribe((res) => {
|
||||||
|
if ('error' in res) {
|
||||||
|
this.switchError = res.error;
|
||||||
|
this.switchInFlight = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.switchCommandId = res.id;
|
||||||
|
this.startSwitchPolling();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll /api/license/channel toutes les 2s. S'arrete quand on detecte un
|
||||||
|
* resultat avec un ID >= a celui qu'on a soumis (le sidecar le met a jour
|
||||||
|
* a la fin de son traitement).
|
||||||
|
*/
|
||||||
|
private startSwitchPolling(): void {
|
||||||
|
this.stopSwitchPolling();
|
||||||
|
this.switchPollSub = interval(2000).pipe(
|
||||||
|
switchMap(() => this.licenseService.getChannelStatus())
|
||||||
|
).subscribe((status) => {
|
||||||
|
if (!status) return;
|
||||||
|
this.channelStatus = status;
|
||||||
|
const last = status.lastSwitch;
|
||||||
|
if (!last || last.id !== this.switchCommandId) return;
|
||||||
|
if (last.status === 'SUCCESS') {
|
||||||
|
// La page va se rafraichir auto via l'update-banner qui detecte le
|
||||||
|
// restart de Core. On laisse switchInFlight a true pour bloquer
|
||||||
|
// toute autre action en attendant.
|
||||||
|
this.stopSwitchPolling();
|
||||||
|
this.switchInFlight = false;
|
||||||
|
} else if (last.status === 'ERROR') {
|
||||||
|
this.switchError = last.message || 'Echec du switch';
|
||||||
|
this.stopSwitchPolling();
|
||||||
|
this.switchInFlight = false;
|
||||||
|
}
|
||||||
|
// IN_PROGRESS : on continue a poll.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopSwitchPolling(): void {
|
||||||
|
if (this.switchPollSub) {
|
||||||
|
this.switchPollSub.unsubscribe();
|
||||||
|
this.switchPollSub = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.stopSwitchPolling();
|
||||||
|
if (this.pullSubscription) {
|
||||||
|
this.pullSubscription.unsubscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping tier_id Patreon → nom lisible. Les IDs viennent du dashboard
|
||||||
|
* Patreon de LoreMind (Settings -> Tiers). Sans entree dans la map, on
|
||||||
|
* affiche l'ID brut pour rester debuggable.
|
||||||
|
*
|
||||||
|
* Si tu ajoutes un nouveau tier Patreon, complete cette map et redeploie.
|
||||||
|
* (Pas besoin de toucher au backend — c'est juste un libelle d'UI.)
|
||||||
|
*/
|
||||||
|
private static readonly TIER_LABELS: Record<string, string> = {
|
||||||
|
'28448887': 'Compagnon',
|
||||||
|
// '0000000': 'Aventurier',
|
||||||
|
// '0000000': 'Heros',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Libelle lisible d'un tier Patreon, fallback sur l'ID brut. */
|
||||||
|
tierLabel(tierId: string | null | undefined): string {
|
||||||
|
if (!tierId) return '';
|
||||||
|
return SettingsComponent.TIER_LABELS[tierId] ?? tierId;
|
||||||
|
}
|
||||||
|
|
||||||
/** Format human-readable des dates renvoyees par le backend. */
|
/** Format human-readable des dates renvoyees par le backend. */
|
||||||
formatDate(iso: string | null | undefined): string {
|
formatDate(iso: string | null | undefined): string {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
@@ -256,9 +398,14 @@ export class SettingsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applyUpdate(): void {
|
applyUpdate(): void {
|
||||||
if (!confirm('Telecharger et redemarrer les conteneurs maintenant ? L\'app sera indisponible quelques secondes.')) {
|
this.confirmDialog.confirm({
|
||||||
return;
|
title: 'Mettre a jour',
|
||||||
}
|
message: 'Telecharger et redemarrer les conteneurs maintenant ?',
|
||||||
|
details: ['L\'app sera indisponible quelques secondes.'],
|
||||||
|
confirmLabel: 'Mettre à jour',
|
||||||
|
variant: 'warning'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
this.updateApplying = true;
|
this.updateApplying = true;
|
||||||
this.updateMessage = '';
|
this.updateMessage = '';
|
||||||
this.updatesService.apply().subscribe({
|
this.updatesService.apply().subscribe({
|
||||||
@@ -274,6 +421,7 @@ export class SettingsComponent implements OnInit {
|
|||||||
this.updateMessage = 'Mise a jour declenchee. Rechargez la page dans 30s.';
|
this.updateMessage = 'Mise a jour declenchee. Rechargez la page dans 30s.';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
loadSettings(): void {
|
loadSettings(): void {
|
||||||
@@ -491,7 +639,14 @@ export class SettingsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deleteModel(name: string): void {
|
deleteModel(name: string): void {
|
||||||
if (!confirm(`Supprimer le modele '${name}' ? L'espace disque sera libere.`)) return;
|
this.confirmDialog.confirm({
|
||||||
|
title: 'Supprimer le modele',
|
||||||
|
message: `Supprimer le modele '${name}' ?`,
|
||||||
|
details: ['L\'espace disque sera libere.'],
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
this.deletingModel = name;
|
this.deletingModel = name;
|
||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
this.settingsService.deleteOllamaModel(name).subscribe({
|
this.settingsService.deleteOllamaModel(name).subscribe({
|
||||||
@@ -511,6 +666,7 @@ export class SettingsComponent implements OnInit {
|
|||||||
this.errorMessage = this.extractError(err, `Echec de la suppression de ${name}.`);
|
this.errorMessage = this.extractError(err, `Echec de la suppression de ${name}.`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
goBack(): void {
|
goBack(): void {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { AiChatService, ChatMessage, ChatUsage, NarrativeEntityType } from '../.
|
|||||||
import { Conversation, ConversationContext } from '../../services/conversation.model';
|
import { Conversation, ConversationContext } from '../../services/conversation.model';
|
||||||
import { ConversationService } from '../../services/conversation.service';
|
import { ConversationService } from '../../services/conversation.service';
|
||||||
import { MarkdownPipe } from '../markdown.pipe';
|
import { MarkdownPipe } from '../markdown.pipe';
|
||||||
|
import { ConfirmDialogService } from '../confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Action primaire optionnelle rendue en gros bouton au-dessus des suggestions.
|
* Action primaire optionnelle rendue en gros bouton au-dessus des suggestions.
|
||||||
@@ -119,6 +120,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly chatService: AiChatService,
|
private readonly chatService: AiChatService,
|
||||||
private readonly conversationService: ConversationService,
|
private readonly conversationService: ConversationService,
|
||||||
|
private readonly confirmDialog: ConfirmDialogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// --- Jauge de contexte --------------------------------------------------
|
// --- Jauge de contexte --------------------------------------------------
|
||||||
@@ -312,13 +314,20 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
deleteConversation(conv: Conversation, event: Event): void {
|
deleteConversation(conv: Conversation, event: Event): void {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (this.isStreaming) return;
|
if (this.isStreaming) return;
|
||||||
if (!confirm(`Supprimer la conversation "${conv.title}" ?`)) return;
|
this.confirmDialog.confirm({
|
||||||
|
title: 'Supprimer la conversation',
|
||||||
|
message: `Supprimer la conversation "${conv.title}" ?`,
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
this.conversationService.delete(conv.id).subscribe({
|
this.conversationService.delete(conv.id).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.conversations = this.conversations.filter((c) => c.id !== conv.id);
|
this.conversations = this.conversations.filter((c) => c.id !== conv.id);
|
||||||
if (this.currentConversationId === conv.id) this.resetConversationState();
|
if (this.currentConversationId === conv.id) this.resetConversationState();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
startRenameTitle(): void {
|
startRenameTitle(): void {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ConfirmDialogComponent } from './confirm-dialog.component';
|
||||||
|
import { ConfirmDialogService } from './confirm-dialog.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-confirm-dialog-host',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, ConfirmDialogComponent],
|
||||||
|
template: `
|
||||||
|
<app-confirm-dialog
|
||||||
|
*ngIf="(svc.state$ | async) as s"
|
||||||
|
[open]="s.open"
|
||||||
|
[title]="s.title"
|
||||||
|
[message]="s.message"
|
||||||
|
[details]="s.details"
|
||||||
|
[confirmLabel]="s.confirmLabel"
|
||||||
|
[cancelLabel]="s.cancelLabel"
|
||||||
|
[variant]="s.variant"
|
||||||
|
(confirmed)="svc.resolve(true)"
|
||||||
|
(cancelled)="svc.resolve(false)">
|
||||||
|
</app-confirm-dialog>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class ConfirmDialogHostComponent {
|
||||||
|
constructor(public svc: ConfirmDialogService) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<div class="confirm-backdrop" *ngIf="open" (click)="onCancel()">
|
||||||
|
<div class="confirm-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
[attr.aria-label]="title"
|
||||||
|
[class.variant-warning]="variant === 'warning'"
|
||||||
|
[class.variant-danger]="variant === 'danger'"
|
||||||
|
[class.variant-info]="variant === 'info'"
|
||||||
|
(click)="$event.stopPropagation()">
|
||||||
|
|
||||||
|
<div class="confirm-header">
|
||||||
|
<div class="confirm-icon">
|
||||||
|
<lucide-icon [img]="TriangleAlert" [size]="22"></lucide-icon>
|
||||||
|
</div>
|
||||||
|
<h2>{{ title }}</h2>
|
||||||
|
<button type="button" class="btn-close" (click)="onCancel()" aria-label="Fermer">
|
||||||
|
<lucide-icon [img]="X" [size]="18"></lucide-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="confirm-body">
|
||||||
|
<p class="confirm-message">{{ message }}</p>
|
||||||
|
<ul *ngIf="details.length > 0" class="confirm-details">
|
||||||
|
<li *ngFor="let line of details">{{ line }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="confirm-actions">
|
||||||
|
<button type="button" class="btn-secondary" (click)="onCancel()">{{ cancelLabel }}</button>
|
||||||
|
<button type="button" class="btn-confirm" (click)="onConfirm()">{{ confirmLabel }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
127
web/src/app/shared/confirm-dialog/confirm-dialog.component.scss
Normal file
127
web/src/app/shared/confirm-dialog/confirm-dialog.component.scss
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
.confirm-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-modal {
|
||||||
|
background: #111827;
|
||||||
|
border: 1px solid #1f2937;
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&.variant-warning { border-top: 4px solid #eab308; }
|
||||||
|
&.variant-danger { border-top: 4px solid #ef4444; }
|
||||||
|
&.variant-info { border-top: 4px solid #6c63ff; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid #1f2937;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
flex: 1;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.variant-warning & { background: rgba(234, 179, 8, 0.15); color: #eab308; }
|
||||||
|
.variant-danger & { background: rgba(239, 68, 68, 0.15); color: #ef4444; }
|
||||||
|
.variant-info & { background: rgba(108, 99, 255, 0.15); color: #6c63ff; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
transition: color 0.2s;
|
||||||
|
|
||||||
|
&:hover { color: white; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-body {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
color: #d1d5db;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-message {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-details {
|
||||||
|
margin: 0.875rem 0 0 1.25rem;
|
||||||
|
padding: 0;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
|
||||||
|
li { margin-bottom: 0.25rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background: #0d121d;
|
||||||
|
border-top: 1px solid #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 0.6rem 1.25rem;
|
||||||
|
background: #1f2937;
|
||||||
|
color: #d1d5db;
|
||||||
|
border: 1px solid #374151;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover { background: #374151; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm {
|
||||||
|
padding: 0.6rem 1.25rem;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
.variant-warning & { background: #eab308; color: #1f1300; &:hover { background: #d4a106; } }
|
||||||
|
.variant-danger & { background: #ef4444; &:hover { background: #dc2626; } }
|
||||||
|
.variant-info & { background: #6c63ff; &:hover { background: #5b52e0; } }
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { LucideAngularModule, TriangleAlert, X } from 'lucide-angular';
|
||||||
|
|
||||||
|
export type ConfirmDialogVariant = 'warning' | 'danger' | 'info';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-confirm-dialog',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, LucideAngularModule],
|
||||||
|
templateUrl: './confirm-dialog.component.html',
|
||||||
|
styleUrls: ['./confirm-dialog.component.scss']
|
||||||
|
})
|
||||||
|
export class ConfirmDialogComponent {
|
||||||
|
readonly TriangleAlert = TriangleAlert;
|
||||||
|
readonly X = X;
|
||||||
|
|
||||||
|
@Input() open = false;
|
||||||
|
@Input() title = 'Confirmation';
|
||||||
|
@Input() message = '';
|
||||||
|
@Input() details: string[] = [];
|
||||||
|
@Input() confirmLabel = 'Confirmer';
|
||||||
|
@Input() cancelLabel = 'Annuler';
|
||||||
|
@Input() variant: ConfirmDialogVariant = 'warning';
|
||||||
|
|
||||||
|
@Output() confirmed = new EventEmitter<void>();
|
||||||
|
@Output() cancelled = new EventEmitter<void>();
|
||||||
|
|
||||||
|
onConfirm(): void { this.confirmed.emit(); }
|
||||||
|
onCancel(): void { this.cancelled.emit(); }
|
||||||
|
}
|
||||||
59
web/src/app/shared/confirm-dialog/confirm-dialog.service.ts
Normal file
59
web/src/app/shared/confirm-dialog/confirm-dialog.service.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { BehaviorSubject } from 'rxjs';
|
||||||
|
import { ConfirmDialogVariant } from './confirm-dialog.component';
|
||||||
|
|
||||||
|
export interface ConfirmDialogOptions {
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
details?: string[];
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
variant?: ConfirmDialogVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfirmDialogState extends Required<Omit<ConfirmDialogOptions, 'details'>> {
|
||||||
|
details: string[];
|
||||||
|
open: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLOSED_STATE: ConfirmDialogState = {
|
||||||
|
open: false,
|
||||||
|
title: 'Confirmation',
|
||||||
|
message: '',
|
||||||
|
details: [],
|
||||||
|
confirmLabel: 'Confirmer',
|
||||||
|
cancelLabel: 'Annuler',
|
||||||
|
variant: 'warning'
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ConfirmDialogService {
|
||||||
|
readonly state$ = new BehaviorSubject<ConfirmDialogState>(CLOSED_STATE);
|
||||||
|
private resolver: ((value: boolean) => void) | null = null;
|
||||||
|
|
||||||
|
confirm(opts: ConfirmDialogOptions): Promise<boolean> {
|
||||||
|
// Si un dialog precedent est encore ouvert, on le resout en "false"
|
||||||
|
// avant d'en ouvrir un nouveau pour eviter une fuite de Promise.
|
||||||
|
if (this.resolver) {
|
||||||
|
this.resolver(false);
|
||||||
|
this.resolver = null;
|
||||||
|
}
|
||||||
|
this.state$.next({
|
||||||
|
open: true,
|
||||||
|
title: opts.title ?? 'Confirmation',
|
||||||
|
message: opts.message,
|
||||||
|
details: opts.details ?? [],
|
||||||
|
confirmLabel: opts.confirmLabel ?? 'Confirmer',
|
||||||
|
cancelLabel: opts.cancelLabel ?? 'Annuler',
|
||||||
|
variant: opts.variant ?? 'warning'
|
||||||
|
});
|
||||||
|
return new Promise<boolean>((resolve) => { this.resolver = resolve; });
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(value: boolean): void {
|
||||||
|
const r = this.resolver;
|
||||||
|
this.resolver = null;
|
||||||
|
this.state$.next(CLOSED_STATE);
|
||||||
|
if (r) r(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
<section *ngIf="s.kind === 'TEXT'" class="pv-section">
|
<section *ngIf="s.kind === 'TEXT'" class="pv-section">
|
||||||
<h2 class="pv-section-title">{{ s.name }}</h2>
|
<h2 class="pv-section-title">{{ s.name }}</h2>
|
||||||
<div class="pv-section-body">
|
<div class="pv-section-body">
|
||||||
<p [class.with-dropcap]="s.name === firstTextSectionName" class="pv-paragraph">
|
<p class="pv-paragraph">
|
||||||
{{ firstParagraph(s.value) }}
|
{{ firstParagraph(s.value) }}
|
||||||
</p>
|
</p>
|
||||||
<p *ngIf="restAfterFirstParagraph(s.value)" class="pv-paragraph">
|
<p *ngIf="restAfterFirstParagraph(s.value)" class="pv-paragraph">
|
||||||
|
|||||||
@@ -290,17 +290,6 @@
|
|||||||
.pv-paragraph {
|
.pv-paragraph {
|
||||||
margin: 0 0 14px;
|
margin: 0 0 14px;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|
||||||
&.with-dropcap::first-letter {
|
|
||||||
float: left;
|
|
||||||
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
|
|
||||||
font-size: 3.5rem;
|
|
||||||
line-height: 0.9;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #d1a878;
|
|
||||||
padding: 4px 8px 0 0;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Etat vide --------------------------------------------------------------
|
// --- Etat vide --------------------------------------------------------------
|
||||||
|
|||||||
@@ -111,15 +111,7 @@ export class PersonaViewComponent {
|
|||||||
return this.rendered().sections;
|
return this.rendered().sections;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pour la drop cap : seul le 1er TEXT la recoit. */
|
/** Premier paragraphe d'un texte (separe pour permettre un styling specifique). */
|
||||||
get firstTextSectionName(): string | null {
|
|
||||||
for (const s of this.orderedSections) {
|
|
||||||
if (s.kind === 'TEXT') return s.name;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Premier paragraphe d'un texte (utilise pour la drop cap). */
|
|
||||||
firstParagraph(text: string): string {
|
firstParagraph(text: string): string {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
const paragraphs = text.split(/\n\s*\n/);
|
const paragraphs = text.split(/\n\s*\n/);
|
||||||
|
|||||||
Reference in New Issue
Block a user