Compare commits

..

56 Commits

Author SHA1 Message Date
7c74c12f3e Changement du readme
Some checks failed
E2E Tests / e2e (push) Failing after 58s
2026-05-17 18:04:24 +02:00
86836ad81c Refonte de toute la partie fiche de personnage avec mise en place d'un nouveau bloc de liste d'attribut (pour tout ce qui sera statistiques, compétences etc....)
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m5s
Build & Push Images / build (core) (push) Successful in 1m38s
Build & Push Images / build (web) (push) Successful in 1m36s
Passage V0.8.3
2026-04-30 15:53:38 +02:00
7c4a42327d Mise en place du picker d'image pour la partie header / illustration des fiches personnage
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m1s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build (web) (push) Successful in 1m42s
Migration pour l'ancienne partie des fiches perso vers les nouvelles pages
Vue retravaillée pour les fiches perso
2026-04-30 10:54:27 +02:00
52e389db24 Refonte du système JDR + système de personnage joueurs / non joueurs :
Some checks failed
E2E Tests / e2e (push) Failing after 21s
- Système de templating dans le game system : en effet, les templates sont liés au game system car les fiches personnages ne sont pas forcément les même selon les jeux (perso Dnd possède + de compétences que Nimble par exemple)
- changement des fiches personnages pour adapter le templating au niveau des campagnes et remplir des pages de perso
2026-04-30 10:42:09 +02:00
efaf5a3794 Mise en place d'un composant permettant d'améliorer l'experience de mise à jour (via un rafraichissement de l'appli).
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m0s
Build & Push Images / build (core) (push) Successful in 1m47s
Build & Push Images / build (web) (push) Successful in 1m38s
Modification de la partie web pour prendre la modification en compte
2026-04-29 14:39:30 +02:00
4fe93b5ff3 Correction problème mise à jour : l'application ne voyait pas les mises à jour quand on lançait docker après avoir push la dernière version.
Some checks failed
E2E Tests / e2e (push) Failing after 21s
Build & Push Images / build (brain) (push) Successful in 1m4s
Build & Push Images / build (core) (push) Successful in 1m31s
Build & Push Images / build (web) (push) Successful in 1m38s
Effectivement : au demarrage, docker ce mettait automatiquement sur la dernière version alors qu'il n'avait pas necessairement récupérer, ducoup comparaison faisait true et on arrivait pas à avoir la derniere version du code.
Push de la clé jwt publique : sinon pas incluse dans le jar finale et la section patreon n'apparaissait pas.
2026-04-29 10:56:37 +02:00
0f2d1b1efe Correction updateCheckServiceTest qui faisait planter le build gitea
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (core) (push) Successful in 1m27s
Build & Push Images / build (web) (push) Successful in 1m34s
Build & Push Images / build (brain) (push) Successful in 53s
2026-04-28 19:12:09 +02:00
5ff05242a8 Mise en place de la connexion au canal privé pour la bêta avec Patreon et passage en v0.8.0
Some checks failed
E2E Tests / e2e (push) Failing after 16s
Build & Push Images / build (brain) (push) Failing after 48s
Build & Push Images / build (core) (push) Failing after 1m18s
Build & Push Images / build (web) (push) Successful in 1m35s
2026-04-28 19:04:11 +02:00
b06c77a1eb Autre patch dockerfile
Some checks failed
E2E Tests / e2e (push) Failing after 20s
Build & Push Images / build (brain) (push) Successful in 58s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build (web) (push) Successful in 1m30s
2026-04-27 22:11:43 +02:00
03bc669efe Patch dockerfile bookworm a lieu de alpine pour corriger le problème de build
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 58s
Build & Push Images / build (core) (push) Successful in 1m39s
Build & Push Images / build (web) (push) Failing after 1m48s
2026-04-27 21:56:04 +02:00
c3873ddd84 Patch dockerfile pour ne plus que le build plante
Some checks failed
E2E Tests / e2e (push) Failing after 16s
Build & Push Images / build (brain) (push) Successful in 1m8s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build (web) (push) Failing after 1m42s
2026-04-27 21:43:13 +02:00
d7ceeac1b0 Correction package-lock
Some checks failed
E2E Tests / e2e (push) Failing after 20s
Build & Push Images / build (brain) (push) Successful in 57s
Build & Push Images / build (core) (push) Successful in 1m34s
Build & Push Images / build (web) (push) Failing after 1m41s
2026-04-27 19:17:01 +02:00
cdbd3cd9b4 Modification lors de la création d'élément de campagne : quand on créer un nouvel élément, on arrive sur la modification et non le résumé de l'élément
Some checks failed
E2E Tests / e2e (push) Failing after 23s
Build & Push Images / build (brain) (push) Successful in 58s
Build & Push Images / build (core) (push) Successful in 1m33s
Build & Push Images / build (web) (push) Failing after 1m39s
2026-04-27 19:03:58 +02:00
a708c74425 Correction du soucis de mise à jour via l'application
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 59s
Build & Push Images / build (core) (push) Successful in 1m39s
Build & Push Images / build (web) (push) Successful in 1m36s
2026-04-27 16:19:56 +02:00
9ad7651c44 Passage V0.7.0
Some checks failed
E2E Tests / e2e (push) Failing after 17s
Build & Push Images / build (brain) (push) Successful in 1m13s
Build & Push Images / build (core) (push) Successful in 1m37s
Build & Push Images / build (web) (push) Successful in 1m43s
2026-04-27 15:51:13 +02:00
389392fd1d Changement sur le Readme
Ajout d'une partie spécifique pour des PNJ dans la partie campagne
2026-04-27 15:48:04 +02:00
aaebeaa547 Mise à jour du readme d'accueil pour l'accès à la documentation
Some checks failed
E2E Tests / e2e (push) Failing after 17s
2026-04-27 08:27:38 +02:00
03ee3855f5 Passage version 0.6.14 + résolution d'un soucis sur l'updater depuis la migration sur git
Some checks failed
E2E Tests / e2e (push) Failing after 24s
Build & Push Images / build (brain) (push) Successful in 59s
Build & Push Images / build (core) (push) Successful in 1m36s
Build & Push Images / build (web) (push) Successful in 1m47s
2026-04-26 19:08:49 +02:00
94a39cf3b4 Mise en place de la pipeline pour github plutot que gitea ; mise en place des images docker sur GHCR plutôt que gitea
Some checks failed
E2E Tests / e2e (push) Failing after 22s
Build & Push Images / build (brain) (push) Successful in 58s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build (web) (push) Successful in 1m40s
Passage version v0.6.13
2026-04-26 10:46:46 +02:00
efe6f6c2b0 Empêche la modale de ce fermer tant que le llm n'est pas télécharger
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m20s
Build & Push Images / build (web) (push) Successful in 1m30s
2026-04-26 09:12:36 +02:00
73a9d15786 Forçage HTTP/1.1 pour la partie python et passage en v0.6.11
Some checks failed
E2E Tests / e2e (push) Failing after 20s
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m17s
Build & Push Images / build (web) (push) Successful in 1m27s
2026-04-26 01:55:02 +02:00
dfe05cf2d2 Correction d'un bug lors de tentative de téléchargement de llm pour ollama
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 50s
Build & Push Images / build (core) (push) Successful in 1m22s
Build & Push Images / build (web) (push) Successful in 1m28s
2026-04-26 01:45:39 +02:00
fcba907438 Passage version 0.6.9
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 47s
Build & Push Images / build (core) (push) Successful in 1m18s
Build & Push Images / build (web) (push) Successful in 1m24s
2026-04-26 01:30:35 +02:00
5739602702 Changement du watchtower pour une version plus récente : projet originel abandonné, repris par un fork.
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Failing after 20s
Build & Push Images / build (web) (push) Failing after 21s
Build & Push Images / build (core) (push) Failing after 22s
2026-04-26 01:19:58 +02:00
addf78f01d Mise en place v0.6.8
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 52s
Build & Push Images / build (core) (push) Successful in 1m20s
Build & Push Images / build (web) (push) Successful in 1m30s
Amélioration de l'installation automatique
Ajout de la possibilité de télécharger le llm que l'on veut à l'interieur de l'application en communicant avec ollama
2026-04-26 01:11:04 +02:00
5e04e84ee4 Mise à jour de la conf pour être sur que le cache angular est bien refresh
Some checks failed
E2E Tests / e2e (push) Failing after 20s
Mise à jour des installeurs
Mise en place de secure-host pour ne pas exposer Ollama à l'exterieur
2026-04-26 00:18:49 +02:00
8d5c2e2b7f Correction pour éviter que la fenêtre ce ferme sans qu'on voit le message d'erreur
Some checks failed
E2E Tests / e2e (push) Failing after 16s
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m18s
Build & Push Images / build (web) (push) Successful in 1m24s
2026-04-25 18:37:40 +02:00
788d2c12f2 Ajout d'un .bat pour l'exécution du .ps1
Some checks failed
E2E Tests / e2e (push) Failing after 16s
2026-04-25 18:34:52 +02:00
b25a9746cf Changement sur l'installation automatique : réduction des patterns suspects dans l'installation pour les antivirus (par exemple, monter automatiquement les privilèges en admin...),
Some checks failed
E2E Tests / e2e (push) Failing after 17s
afin d'éviter que l'appli ne soit détectée comme un virus
2026-04-25 18:24:44 +02:00
41fda9aeee Ajout d'un script pour installation automatique du produit
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 45s
Build & Push Images / build (core) (push) Successful in 1m16s
Build & Push Images / build (web) (push) Successful in 1m26s
Ajout d'une partie mise à jour automatique : plus besoin de docker pull en ligne de commande ; on peut passer par l'interface
Refactoring partie Java pour respecter d'avantage le DDD : plus de jackson dans la partie domain

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

Passage version 0.6.5
2026-04-25 11:41:14 +02:00
0582690dca Correction d'un test unitaire
Some checks failed
E2E Tests / e2e (push) Failing after 3m43s
Ajout d'un champs image dans les templates par défaut en + du champs nom, description pour avoir un exemple
Correction du visuel du champs d'ajout lors de la modification d'un template (apparition ligne pleine au lieu de texte en pointillé)
Ajout d'un intercepteur pour la partie démo de l'application afin de bien rafraichir le cache angular lorsque le temps de démo est expiré
2026-04-25 09:23:56 +02:00
88278bd1dd Fix : problème d'ascenseur en bas de la page au niveau des templates
Some checks failed
Build & Push Images / build (brain) (push) Successful in 1m1s
Build & Push Images / build (core) (push) Successful in 1m32s
E2E Tests / e2e (push) Failing after 4m13s
Build & Push Images / build (web) (push) Successful in 1m53s
Sélection du template par défaut lors de la création d'une page en fonction du dossier
Passage v0.6.2
2026-04-25 01:39:05 +02:00
d24d6459a0 Ajout de test, correctif d'un problème d'horloge pour le workflow gitea actions pour le e2e
Some checks failed
E2E Tests / e2e (push) Failing after 3m33s
2026-04-25 00:51:32 +02:00
4b866e5212 Fix workflow gitea action pour e2e (tests automatisés via playwright) + correction d'une incohérence dans l'API coté java. Ajout d'autres tests utilisateur
Some checks failed
E2E Tests / e2e (push) Failing after 2m31s
2026-04-25 00:45:04 +02:00
6c6bd20f0d Mise en place de tests utilisateurs avec playwright pour la partie angular + corrections au niveau des labels avec for et id pour cliquer dessus
Some checks failed
E2E Tests / e2e (push) Failing after 5m30s
2026-04-25 00:25:53 +02:00
2764228abf Fix rate limit derriere Cloudflare + CORS sur POST demo 2026-04-24 08:55:40 +02:00
f95d69c915 Fix CORS 403 sur POST : passer APP_CORS_ALLOWED_ORIGINS au core démo 2026-04-24 08:46:26 +02:00
70351e9d9a Remplace docker SDK par appels HTTP directs (zero deps)
All checks were successful
Build & Push Images / build (brain) (push) Successful in 52s
Build & Push Images / build (core) (push) Successful in 1m28s
Build & Push Images / build (web) (push) Successful in 1m34s
2026-04-24 07:39:49 +02:00
ff4905126d Docker SDK v28 pour resoudre les conflits transitifs 2026-04-24 07:33:48 +02:00
0e5b5a7de4 Correction d'une dépendance go 2026-04-24 07:30:20 +02:00
c8c032336b Mise à jour du dockerfile suite à une dépendance trop ancienne sur go 2026-04-24 07:26:42 +02:00
dda27e55fc Orchestrateur go pour lancer la démo et mettre en place plusieurs instances docker 2026-04-23 17:49:26 +02:00
83ac67471e Changement dans la config pour éviter les url en dur + mise en place d'un mode démo 2026-04-23 17:15:08 +02:00
e3c8232e38 Version 0.6.1
All checks were successful
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m19s
Build & Push Images / build (web) (push) Successful in 1m31s
2026-04-23 14:36:09 +02:00
a4df9fc759 Ajout des personnage dans la sidebar de la campagne 2026-04-23 14:34:07 +02:00
f1989c1d77 Mutualisation de la version pour ne pas l'oublier dans le footer du front ;
All checks were successful
Build & Push Images / build (brain) (push) Successful in 50s
Build & Push Images / build (core) (push) Successful in 1m29s
Build & Push Images / build (web) (push) Successful in 1m24s
Passage en version v 0.6.0
2026-04-23 14:12:24 +02:00
8efdf5d0e0 Correction bug suppression complète coté lore (et suppression dans tout ce qui est campagne de la partie lore liée).
Améliorations ux :
- Bandeau en haut qui reste accessible lors de la création d'un élément (chapitre, page, scène etc...)
- Mise en place d'un surlignage pour voir su quel élément on est positionné
2026-04-23 14:06:50 +02:00
96bc5de942 Mise en place de la possibilité de supprimer des lores / campagnes d'un seul coup 2026-04-23 11:51:03 +02:00
84ccdd53ad Corrections d'ordre graphique / ergonomique :
- Lorsqu'on part de zéro : la création de dossier / page / template ce fait de manière plus fluide à la création d'un lore (par exemple création de page sans template et dossier : parcours facilité)
- Ajout d'un bouton "+" dans le header templates
- Harmonisation création / modification template

Correction de tests unitaires
2026-04-23 11:25:58 +02:00
29978058ee Correction d'un test unitaire
All checks were successful
Build & Push Images / build (brain) (push) Successful in 49s
Build & Push Images / build (core) (push) Successful in 1m24s
Build & Push Images / build (web) (push) Successful in 1m22s
2026-04-22 13:38:48 +02:00
e510f64336 Passage en v 0.5.0
Some checks failed
Build & Push Images / build (brain) (push) Successful in 47s
Build & Push Images / build (core) (push) Failing after 1m19s
Build & Push Images / build (web) (push) Successful in 1m23s
2026-04-22 13:33:47 +02:00
f189f67aaf Mise en place d'un bouton + au hover plutot qu'un affichage constant 2026-04-22 13:31:06 +02:00
8efa148739 Corrections visuel ; optimisation du chargement des pages (préchargement anticité, sinon temps de latence chaque fois qu'on visite un type de page une première fois) 2026-04-22 13:17:05 +02:00
8f4dd3e9d6 Ajout de la partie "Système de jeu" avec toute la partie stockage de règles de notre jeu.
Ajout de possibilité de stocker des fiches de personnages associés à une campagne également (personnages joueurs pour le moment)
2026-04-22 11:58:50 +02:00
bf38b6695f Ecriture de tests unitaires coté java pour améliorer la stabilité de l'application 2026-04-22 07:46:24 +02:00
71 changed files with 497 additions and 1919 deletions

View File

@@ -42,24 +42,19 @@ jobs:
username: ${{ env.GHCR_NAMESPACE }}
password: ${{ secrets.GHCR_TOKEN }}
# 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
- name: Extract version
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
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
# Build & push canal STABLE
- name: Build & push ${{ matrix.component }} (stable)
if: steps.meta.outputs.channel == 'stable'
# Push vers les deux registries en un seul build (build-push-action
# accepte une liste de tags ; aucun build supplementaire necessaire).
# Naming :
# - 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
with:
context: ./${{ matrix.component }}
@@ -69,19 +64,3 @@ jobs:
${{ 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 }}:${{ 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 }}

1
.gitignore vendored
View File

@@ -107,4 +107,3 @@ docker-compose.override.yml
# Relais OAuth Patreon (repo Gitea separe, clone localement pour facilite)
# ============================================================================
relay/
scripts/bump-version.mjs

View File

@@ -41,7 +41,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
app = FastAPI(
title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.",
version="0.8.4-beta",
version="0.8.3",
)

View File

@@ -8,13 +8,13 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.12</version>
<version>3.2.0</version>
<relativePath/>
</parent>
<groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId>
<version>0.8.4-beta</version>
<version>0.8.3</version>
<name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description>
@@ -96,15 +96,6 @@
<artifactId>bcprov-jdk18on</artifactId>
<version>1.78.1</version>
</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>
<build>

View File

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

View File

@@ -355,55 +355,3 @@ export async function getTemplateById(
expect(res.ok(), `GET /api/templates/${templateId} -> ${res.status()}`).toBeTruthy();
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();
}

View File

@@ -24,12 +24,10 @@ test.describe('Arc delete', () => {
page,
request,
}) => {
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
page.on('dialog', (dialog) => dialog.accept());
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: /^Supprimer$/i }).click();
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
await page.getByRole('button', { name: /^Supprimer$/i }).click();
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
@@ -38,12 +36,10 @@ test.describe('Arc delete', () => {
});
test('keeps the arc when confirm is dismissed', async ({ page, request }) => {
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
page.on('dialog', (dialog) => dialog.dismiss());
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: /^Annuler$/i }).click();
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
await page.getByRole('button', { name: /^Supprimer$/i }).click();
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/arcs/${arc.id}/edit$`));

View File

@@ -38,10 +38,6 @@ test.describe('Arc 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(/Synopsis de l'arc/i).fill(values.description);

View File

@@ -16,12 +16,10 @@ test.describe('Campaign delete', () => {
page,
request,
}) => {
await page.goto(`/campaigns/${campaign.id}`);
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
page.on('dialog', (dialog) => dialog.accept());
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: /^Supprimer$/i }).click();
await page.goto(`/campaigns/${campaign.id}`);
await page.getByRole('button', { name: /^Supprimer$/i }).click();
await expect(page).toHaveURL(/\/campaigns$/);
@@ -30,12 +28,10 @@ test.describe('Campaign delete', () => {
});
test('keeps the campaign when confirm is dismissed', async ({ page, request }) => {
await page.goto(`/campaigns/${campaign.id}`);
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
page.on('dialog', (dialog) => dialog.dismiss());
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: /^Annuler$/i }).click();
await page.goto(`/campaigns/${campaign.id}`);
await page.getByRole('button', { name: /^Supprimer$/i }).click();
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));

View File

@@ -17,22 +17,20 @@ test.describe('NPC creation', () => {
if (campaign?.id) await deleteCampaign(request, campaign.id);
});
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.
test('creates an NPC and redirects back to the campaign', async ({ page, request }) => {
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 expect(page.getByRole('heading', { name: /Nouveau PNJ/i })).toBeVisible();
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();
// Redirection vers la fiche du PNJ après création
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/npcs/\\d+$`));
// Retour à la page campagne après création
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
// Persistance vérifiée via API
const npcs = await getNpcsByCampaign(request, campaign.id);
@@ -60,7 +58,7 @@ test.describe('NPC creation', () => {
await page.getByLabel(/Nom du PNJ/i).fill(npcName);
await page.getByRole('button', { name: /^Créer$/i }).click();
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/npcs/\\d+$`));
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
// 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é,

View File

@@ -14,19 +14,19 @@ test.describe('NPC edit', () => {
test.beforeEach(async ({ request }) => {
campaign = await seedCampaign(request);
npc = await seedNpc(request, { campaignId: campaign.id });
npc = await seedNpc(request, {
campaignId: campaign.id,
markdownContent: '# Initial\n\nFiche de départ.',
});
});
test.afterEach(async ({ request }) => {
if (campaign?.id) await deleteCampaign(request, campaign.id);
});
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).
test('edits name + markdown content and persists via API', async ({ page, request }) => {
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`);
@@ -34,6 +34,7 @@ test.describe('NPC edit', () => {
await expect(page.getByLabel(/Nom du PNJ/i)).toHaveValue(npc.name);
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();
@@ -42,6 +43,7 @@ test.describe('NPC edit', () => {
const persisted = await getNpcById(request, npc.id);
expect(persisted.name).toBe(newName);
expect(persisted.markdownContent).toBe(newMarkdown);
});
test('save button is disabled when name is cleared', async ({ page }) => {

View File

@@ -1,74 +0,0 @@
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();
});
});

View File

@@ -1,61 +0,0 @@
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();
});
});

View File

@@ -1,68 +0,0 @@
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();
});
});

View File

@@ -1,111 +0,0 @@
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();
});
});

View File

@@ -1,151 +0,0 @@
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();
});
});

View File

@@ -17,31 +17,32 @@ test.describe('Lore delete', () => {
page,
request,
}) => {
let confirmMessage = '';
page.on('dialog', async (dialog) => {
confirmMessage = dialog.message();
await dialog.accept();
});
await page.goto(`/lore/${seeded.id}`);
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
await page.getByRole('button', { name: /^Supprimer$/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog).toContainText(seeded.name);
// Lore contient un dossier seedé : le récapitulatif doit l'indiquer.
await expect(dialog).toContainText(/1 dossier/i);
await dialog.getByRole('button', { name: /^Supprimer$/i }).click();
// Attente du retour sur la liste des lores.
// Attente du dialog et du retour sur la liste des lores.
await expect(page).toHaveURL(/\/lore$/);
expect(confirmMessage).toContain(seeded.name);
// Lore contient un dossier seedé : le récapitulatif doit l'indiquer.
expect(confirmMessage).toMatch(/1 dossier/i);
const res = await request.get(`/api/lores/${seeded.id}`);
expect(res.status()).toBe(404);
});
test('keeps the lore when the confirm is dismissed', async ({ page, request }) => {
await page.goto(`/lore/${seeded.id}`);
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
page.on('dialog', async (dialog) => {
await dialog.dismiss();
});
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: /^Annuler$/i }).click();
await page.goto(`/lore/${seeded.id}`);
await page.getByRole('button', { name: /^Supprimer$/i }).click();
// On reste sur le détail, le titre du lore est toujours visible.
await expect(page.locator('.detail-header h1')).toHaveText(seeded.name);

View File

@@ -32,12 +32,10 @@ test.describe('Page delete', () => {
});
test('deletes the page after accepting confirm', async ({ page, request }) => {
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
page.on('dialog', (dialog) => dialog.accept());
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: /^Supprimer$/i }).click();
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
await page.getByRole('button', { name: /^Supprimer$/i }).click();
// Le composant redirige vers la racine du Lore après suppression.
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
@@ -47,12 +45,10 @@ test.describe('Page delete', () => {
});
test('keeps the page when confirm is dismissed', async ({ page, request }) => {
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
page.on('dialog', (dialog) => dialog.dismiss());
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: /^Annuler$/i }).click();
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
await page.getByRole('button', { name: /^Supprimer$/i }).click();
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/pages/${pageEntity.id}/edit$`));

View File

@@ -25,13 +25,11 @@ test.describe('Template delete', () => {
});
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.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}$`));
const templates = await getTemplatesForLore(request, seeded.id);
@@ -39,13 +37,11 @@ test.describe('Template delete', () => {
});
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.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).
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/templates/${template.id}$`));

4
web/package-lock.json generated
View File

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

View File

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

View File

@@ -1,12 +1,6 @@
import { defineConfig, devices } from '@playwright/test';
// 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';
const baseURL = process.env['E2E_BASE_URL'] || 'http://localhost:8081';
export default defineConfig({
testDir: './e2e/tests',

View File

@@ -18,4 +18,3 @@
</div>
<app-global-search></app-global-search>
<app-confirm-dialog-host></app-confirm-dialog-host>

View File

@@ -5,7 +5,6 @@ import { SidebarComponent } from './sidebar/sidebar.component';
import { SecondarySidebarComponent } from './shared/secondary-sidebar/secondary-sidebar.component';
import { GlobalSearchComponent } from './shared/global-search/global-search.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 { GlobalSearchService } from './services/global-search.service';
import { VersionCheckerService } from './services/version-checker.service';
@@ -19,7 +18,6 @@ import { VersionCheckerService } from './services/version-checker.service';
SecondarySidebarComponent,
GlobalSearchComponent,
UpdateBannerComponent,
ConfirmDialogHostComponent,
AsyncPipe,
NgIf,
],

View File

@@ -7,8 +7,9 @@ import { LucideAngularModule, BookOpen } from 'lucide-angular';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { LayoutService } from '../../../services/layout.service';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { LayoutService, GlobalItem } from '../../../services/layout.service';
import { Campaign } from '../../../services/campaign.model';
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
@@ -61,7 +62,21 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
}).subscribe(({ campaign, allCampaigns, treeData }) => {
this.existingArcCount = treeData.arcs.length;
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
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'
});
});
}
@@ -74,7 +89,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
order: this.existingArcCount + 1,
icon: this.selectedIcon
}).subscribe({
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', created.id]),
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', created.id, 'edit']),
error: () => console.error('Erreur lors de la création de l\'arc')
});
}
@@ -84,9 +99,6 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
// 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.
this.layoutService.hide();
}
}

View File

@@ -9,17 +9,16 @@ import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service';
import { LayoutService, GlobalItem } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Arc } from '../../../services/campaign.model';
import { Campaign, Arc } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
/**
* Écran de détail/modification d'un Arc.
@@ -79,8 +78,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
private npcService: NpcService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService
private pageTitleService: PageTitleService
) {
this.form = this.fb.group({
name: ['', Validators.required],
@@ -144,7 +142,21 @@ export class ArcEditComponent implements OnInit, OnDestroy {
resolution: arc.resolution ?? ''
});
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
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'
});
});
}
@@ -171,18 +183,10 @@ export class ArcEditComponent implements OnInit, OnDestroy {
}
delete(): void {
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({
next: () => this.router.navigate(['/campaigns', this.campaignId]),
error: () => console.error('Erreur lors de la suppression')
});
if (!confirm(`Supprimer l'arc "${this.arc?.name}" ? Cette action est irréversible.`)) return;
this.campaignService.deleteArc(this.arcId).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId]),
error: () => console.error('Erreur lors de la suppression')
});
}
@@ -191,9 +195,6 @@ export class ArcEditComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
// 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.
this.layoutService.hide();
}
}

View File

@@ -9,13 +9,12 @@ import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service';
import { LayoutService, GlobalItem } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Arc } from '../../../services/campaign.model';
import { Campaign, Arc } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
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).
@@ -51,8 +50,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
private npcService: NpcService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService
private pageTitleService: PageTitleService
) {}
ngOnInit(): void {
@@ -85,7 +83,20 @@ export class ArcViewComponent implements OnInit, OnDestroy {
this.availablePages = pages;
this.pageTitleService.set(arc.name);
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
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'
});
});
}
@@ -111,24 +122,18 @@ export class ArcViewComponent implements OnInit, OnDestroy {
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' : ''}`);
const details: string[] = [];
const lines = [`Supprimer l'arc "${arc.name}" ?`];
if (parts.length) {
details.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
lines.push('');
lines.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
}
details.push('Cette action est irréversible.');
lines.push('');
lines.push('Cette action est irréversible.');
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({
next: () => this.router.navigate(['/campaigns', this.campaignId]),
error: () => console.error('Erreur lors de la suppression de l\'arc')
});
if (!confirm(lines.join('\n'))) return;
this.campaignService.deleteArc(arc.id!).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId]),
error: () => console.error('Erreur lors de la suppression de l\'arc')
});
},
error: () => console.error('Impossible de récupérer les dépendances de l\'arc')
@@ -136,9 +141,6 @@ export class ArcViewComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
// 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.
this.layoutService.hide();
}
}

View File

@@ -3,8 +3,8 @@ import { switchMap, map } from 'rxjs/operators';
import { CampaignService } from '../services/campaign.service';
import { CharacterService } from '../services/character.service';
import { NpcService } from '../services/npc.service';
import { TreeItem, SecondarySidebarConfig, GlobalItem } from '../services/layout.service';
import { Arc, Chapter, Scene, Campaign } from '../services/campaign.model';
import { TreeItem } from '../services/layout.service';
import { Arc, Chapter, Scene } from '../services/campaign.model';
import { Character } from '../services/character.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 => ({
id: `character-${ch.id}`,
label: ch.name,
route: `/campaigns/${campaignId}/characters/${ch.id}`
route: `/campaigns/${campaignId}/characters/${ch.id}/edit`
}));
const charactersNode: TreeItem = {
@@ -107,7 +107,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
const npcItems: TreeItem[] = sortedNpcs.map(n => ({
id: `npc-${n.id}`,
label: n.name,
route: `/campaigns/${campaignId}/npcs/${n.id}`
route: `/campaigns/${campaignId}/npcs/${n.id}/edit`
}));
const npcsNode: TreeItem = {
@@ -172,35 +172,3 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
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'
};
}

View File

@@ -50,50 +50,14 @@
<div class="field">
<label for="campaign-game-system">Système de JDR</label>
<select *ngIf="!creatingGameSystem" id="campaign-game-system" formControlName="gameSystemId">
<select id="campaign-game-system" formControlName="gameSystemId">
<option value="">— Aucun (campagne générique) —</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>
<!-- Mode creation inline : remplace temporairement le select. -->
<div *ngIf="creatingGameSystem" class="inline-create">
<input
type="text"
[(ngModel)]="newGameSystemName"
[ngModelOptions]="{ standalone: true }"
placeholder="Nom du nouveau système (ex: D&D 5e, Nimble, Maison)"
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
(keydown.escape)="cancelCreateGameSystem()"
autofocus
/>
<div class="inline-create-actions">
<button type="button" class="btn-inline-primary"
[disabled]="!newGameSystemName.trim() || creatingGameSystemInFlight"
(click)="submitCreateGameSystem()">
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
Créer
</button>
<button type="button" class="btn-inline-secondary" (click)="cancelCreateGameSystem()">
Annuler
</button>
</div>
<p class="hint">
Création rapide — vous pourrez ajouter les règles, les templates de fiches PJ/PNJ
et le reste depuis la section "Systèmes" plus tard.
</p>
</div>
<p *ngIf="!creatingGameSystem" class="hint">
<p class="hint">
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.
</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 class="info-box">

View File

@@ -87,81 +87,6 @@ form {
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 {
background: #1f2937;
border-radius: 8px;

View File

@@ -1,8 +1,7 @@
import { Component, EventEmitter, OnInit, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, BookCopy, X, Plus, Check } from 'lucide-angular';
import { LucideAngularModule, BookCopy, X } from 'lucide-angular';
import { LoreService } from '../../../services/lore.service';
import { Lore } from '../../../services/lore.model';
import { GameSystemService } from '../../../services/game-system.service';
@@ -23,7 +22,7 @@ export interface CampaignCreatePayload {
@Component({
selector: 'app-campaign-create',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, FormsModule, LucideAngularModule],
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule],
templateUrl: './campaign-create.component.html',
styleUrls: ['./campaign-create.component.scss']
})
@@ -33,11 +32,6 @@ export class CampaignCreateComponent implements OnInit {
readonly BookCopy = BookCopy;
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;
/** Lores disponibles pour association. Chargés à l'ouverture de la modal. */
@@ -45,11 +39,6 @@ export class CampaignCreateComponent implements OnInit {
/** GameSystems disponibles pour association. */
availableGameSystems: GameSystem[] = [];
/** Mode creation inline d'un GameSystem depuis le dropdown. */
creatingGameSystem = false;
newGameSystemName = '';
creatingGameSystemInFlight = false;
constructor(
private fb: FormBuilder,
private loreService: LoreService,
@@ -73,47 +62,6 @@ export class CampaignCreateComponent implements OnInit {
next: (gs) => this.availableGameSystems = gs,
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 {

View File

@@ -55,37 +55,10 @@
</div>
<div class="field">
<label>Système de JDR</label>
<select *ngIf="!creatingGameSystem"
[(ngModel)]="editGameSystemId"
name="editGameSystemId"
(ngModelChange)="onEditGameSystemChange($event)">
<select [(ngModel)]="editGameSystemId" name="editGameSystemId">
<option value="">— Aucun (générique) —</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>
<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 class="header-actions">
<button type="button" class="btn-primary" (click)="saveEdit()" [disabled]="!editName.trim()">

View File

@@ -122,64 +122,6 @@
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; }
}
}

View File

@@ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, User, Dices, Drama, Check } from 'lucide-angular';
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, User, Dices, Drama } from 'lucide-angular';
import { Router, RouterLink } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { catchError, switchMap, filter, map } from 'rxjs/operators';
@@ -14,12 +14,11 @@ import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { Character } from '../../../services/character.model';
import { Npc } from '../../../services/npc.model';
import { LayoutService } from '../../../services/layout.service';
import { LayoutService, GlobalItem } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Campaign, Arc } from '../../../services/campaign.model';
import { Lore } from '../../../services/lore.model';
import { loadCampaignTreeData, buildCampaignSidebarConfig, CampaignTreeData } from '../../campaign-tree.helper';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
import { loadCampaignTreeData, buildCampaignTree, CampaignTreeData } from '../../campaign-tree.helper';
@Component({
selector: 'app-campaign-detail',
@@ -37,7 +36,6 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
readonly User = User;
readonly Dices = Dices;
readonly Drama = Drama;
readonly Check = Check;
campaign: Campaign | null = null;
arcs: Arc[] = [];
@@ -63,13 +61,6 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
editLoreId = '';
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(
private route: ActivatedRoute,
private router: Router,
@@ -79,8 +70,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
private characterService: CharacterService,
private npcService: NpcService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService
private pageTitleService: PageTitleService
) {}
ngOnInit(): void {
@@ -251,7 +241,24 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
}
private showLayout(allCampaigns: Campaign[], data: CampaignTreeData): void {
this.layoutService.show(buildCampaignSidebarConfig(this.campaign!, allCampaigns, data, this.campaign!.id!));
const campaignId = 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 ───────────────
@@ -276,83 +283,16 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
cancelEdit(): void {
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 {
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!, {
name: this.editName.trim(),
description: this.editDescription,
playerCount: this.campaign.playerCount ?? 0,
loreId: this.editLoreId ? this.editLoreId : null,
gameSystemId: newGameSystemId
gameSystemId: this.editGameSystemId ? this.editGameSystemId : null
}).subscribe({
next: (updated) => {
this.campaign = updated;
@@ -381,22 +321,18 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
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' : ''}`);
const details: string[] = [];
if (parts.length) details.push(`Sera aussi supprime : ${parts.join(', ')}.`);
details.push('Cette action est irreversible.');
const lines = [`Supprimer définitivement la campagne "${campaign.name}" ?`];
if (parts.length) {
lines.push('');
lines.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
}
lines.push('');
lines.push('Cette action est irréversible.');
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({
next: () => this.router.navigate(['/campaigns']),
error: () => console.error('Erreur lors de la suppression de la campagne')
});
if (!confirm(lines.join('\n'))) return;
this.campaignService.deleteCampaign(campaign.id!).subscribe({
next: () => this.router.navigate(['/campaigns']),
error: () => console.error('Erreur lors de la suppression de la campagne')
});
},
error: () => console.error('Impossible de récupérer les dépendances de la campagne')

View File

@@ -7,8 +7,9 @@ import { LucideAngularModule } from 'lucide-angular';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { LayoutService } from '../../../services/layout.service';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { LayoutService, GlobalItem } from '../../../services/layout.service';
import { Campaign } from '../../../services/campaign.model';
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
@@ -64,7 +65,21 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
this.arcName = currentArc?.name ?? '';
this.existingChapterCount = treeData.chaptersByArc[this.arcId]?.length ?? 0;
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
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'
});
});
}
@@ -87,9 +102,6 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
// 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.
this.layoutService.hide();
}
}

View File

@@ -9,17 +9,16 @@ import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service';
import { LayoutService, GlobalItem } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Chapter } from '../../../services/campaign.model';
import { Campaign, Chapter } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
/**
* Écran de détail/modification d'un Chapitre.
@@ -72,8 +71,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
private npcService: NpcService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService
private pageTitleService: PageTitleService
) {
this.form = this.fb.group({
name: ['', Validators.required],
@@ -132,7 +130,21 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
narrativeStakes: chapter.narrativeStakes ?? ''
});
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
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'
});
});
}
@@ -157,18 +169,10 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
}
delete(): void {
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({
next: () => this.router.navigate(['/campaigns', this.campaignId]),
error: () => console.error('Erreur lors de la suppression')
});
if (!confirm(`Supprimer le chapitre "${this.chapter?.name}" ? Cette action est irréversible.`)) return;
this.campaignService.deleteChapter(this.chapterId).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId]),
error: () => console.error('Erreur lors de la suppression')
});
}
@@ -177,9 +181,6 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
// 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.
this.layoutService.hide();
}
}

View File

@@ -371,9 +371,6 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
// 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.
this.layoutService.hide();
}
}

View File

@@ -9,13 +9,12 @@ import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service';
import { LayoutService, GlobalItem } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Chapter } from '../../../services/campaign.model';
import { Campaign, Chapter } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
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).
@@ -50,8 +49,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
private npcService: NpcService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService
private pageTitleService: PageTitleService
) {}
ngOnInit(): void {
@@ -88,7 +86,20 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
this.availablePages = pages;
this.pageTitleService.set(chapter.name);
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
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'
});
});
}
@@ -117,24 +128,18 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
const chapter = this.chapter;
this.campaignService.getChapterDeletionImpact(chapter.id!).subscribe({
next: impact => {
const details: string[] = [];
const lines = [`Supprimer le chapitre "${chapter.name}" ?`];
if (impact.scenes > 0) {
details.push(`Cette action supprimera aussi : ${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}.`);
lines.push('');
lines.push(`Cette action supprimera aussi : ${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}.`);
}
details.push('Cette action est irréversible.');
lines.push('');
lines.push('Cette action est irréversible.');
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({
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]),
error: () => console.error('Erreur lors de la suppression du chapitre')
});
if (!confirm(lines.join('\n'))) return;
this.campaignService.deleteChapter(chapter.id!).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]),
error: () => console.error('Erreur lors de la suppression du chapitre')
});
},
error: () => console.error('Impossible de récupérer les dépendances du chapitre')
@@ -142,9 +147,6 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
// 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.
this.layoutService.hide();
}
}

View File

@@ -26,9 +26,8 @@
<div class="ce-form">
<div class="field">
<label for="character-name">Nom du personnage *</label>
<label>Nom du personnage *</label>
<input
id="character-name"
type="text"
[(ngModel)]="name"
name="name"

View File

@@ -6,12 +6,10 @@ import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lu
import { CharacterService } from '../../../services/character.service';
import { CampaignService } from '../../../services/campaign.service';
import { GameSystemService } from '../../../services/game-system.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { TemplateField } from '../../../services/template.model';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.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).
@@ -64,9 +62,7 @@ export class CharacterEditComponent implements OnInit {
private router: Router,
private service: CharacterService,
private campaignService: CampaignService,
private gameSystemService: GameSystemService,
private campaignSidebar: CampaignSidebarService,
private confirmDialog: ConfirmDialogService
private gameSystemService: GameSystemService
) {}
ngOnInit(): void {
@@ -76,7 +72,6 @@ export class CharacterEditComponent implements OnInit {
if (this.campaignId) {
this.loadTemplateForCampaign(this.campaignId);
this.campaignSidebar.show(this.campaignId);
}
if (this.characterId) {
@@ -111,7 +106,6 @@ export class CharacterEditComponent implements OnInit {
});
}
submit(): void {
if (!this.name.trim() || !this.campaignId) return;
const payload = {
@@ -123,36 +117,21 @@ export class CharacterEditComponent implements OnInit {
keyValueValues: this.keyValueValues,
campaignId: this.campaignId
};
const isCreation = !this.characterId;
const req = this.characterId
? this.service.update(this.characterId, { ...payload, id: this.characterId, order: this.order })
: this.service.create(payload);
req.subscribe({
next: (saved) => {
if (isCreation && saved.id) {
this.router.navigate(['/campaigns', this.campaignId, 'characters', saved.id]);
} else {
this.back();
}
},
next: () => this.back(),
error: () => console.error('Erreur sauvegarde Character')
});
}
deleteCharacter(): void {
if (!this.characterId) 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({
next: () => this.back(),
error: () => console.error('Erreur suppression Character')
});
if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irreversible.`)) return;
this.service.delete(this.characterId).subscribe({
next: () => this.back(),
error: () => console.error('Erreur suppression Character')
});
}

View File

@@ -5,7 +5,6 @@ import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular'
import { CharacterService } from '../../../services/character.service';
import { CampaignService } from '../../../services/campaign.service';
import { GameSystemService } from '../../../services/game-system.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { TemplateField } from '../../../services/template.model';
import { Character } from '../../../services/character.model';
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
@@ -41,8 +40,7 @@ export class CharacterViewComponent implements OnInit {
private router: Router,
private service: CharacterService,
private campaignService: CampaignService,
private gameSystemService: GameSystemService,
private campaignSidebar: CampaignSidebarService
private gameSystemService: GameSystemService
) {}
ngOnInit(): void {
@@ -56,7 +54,6 @@ export class CharacterViewComponent implements OnInit {
});
}
if (this.campaignId) {
this.campaignSidebar.show(this.campaignId);
this.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
if (camp.gameSystemId) {
this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => {

View File

@@ -26,9 +26,8 @@
<div class="ne-form">
<div class="field">
<label for="npc-name">Nom du PNJ *</label>
<label>Nom du PNJ *</label>
<input
id="npc-name"
type="text"
[(ngModel)]="name"
name="name"

View File

@@ -6,12 +6,10 @@ import { LucideAngularModule, Save, ArrowLeft, Drama, Trash2, Sparkles } from 'l
import { NpcService } from '../../../services/npc.service';
import { CampaignService } from '../../../services/campaign.service';
import { GameSystemService } from '../../../services/game-system.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { TemplateField } from '../../../services/template.model';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.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.
@@ -59,9 +57,7 @@ export class NpcEditComponent implements OnInit {
private router: Router,
private service: NpcService,
private campaignService: CampaignService,
private gameSystemService: GameSystemService,
private campaignSidebar: CampaignSidebarService,
private confirmDialog: ConfirmDialogService
private gameSystemService: GameSystemService
) {}
ngOnInit(): void {
@@ -71,7 +67,6 @@ export class NpcEditComponent implements OnInit {
if (this.campaignId) {
this.loadTemplateForCampaign(this.campaignId);
this.campaignSidebar.show(this.campaignId);
}
if (this.npcId) {
@@ -106,7 +101,6 @@ export class NpcEditComponent implements OnInit {
});
}
submit(): void {
if (!this.name.trim() || !this.campaignId) return;
const payload = {
@@ -118,36 +112,21 @@ export class NpcEditComponent implements OnInit {
keyValueValues: this.keyValueValues,
campaignId: this.campaignId
};
const isCreation = !this.npcId;
const req = this.npcId
? this.service.update(this.npcId, { ...payload, id: this.npcId, order: this.order })
: this.service.create(payload);
req.subscribe({
next: (saved) => {
if (isCreation && saved.id) {
this.router.navigate(['/campaigns', this.campaignId, 'npcs', saved.id]);
} else {
this.back();
}
},
next: () => this.back(),
error: () => console.error('Erreur sauvegarde Npc')
});
}
deleteNpc(): void {
if (!this.npcId) 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({
next: () => this.back(),
error: () => console.error('Erreur suppression Npc')
});
if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irreversible.`)) return;
this.service.delete(this.npcId).subscribe({
next: () => this.back(),
error: () => console.error('Erreur suppression Npc')
});
}

View File

@@ -5,7 +5,6 @@ import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular'
import { NpcService } from '../../../services/npc.service';
import { CampaignService } from '../../../services/campaign.service';
import { GameSystemService } from '../../../services/game-system.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { TemplateField } from '../../../services/template.model';
import { Npc } from '../../../services/npc.model';
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
@@ -41,8 +40,7 @@ export class NpcViewComponent implements OnInit {
private router: Router,
private service: NpcService,
private campaignService: CampaignService,
private gameSystemService: GameSystemService,
private campaignSidebar: CampaignSidebarService
private gameSystemService: GameSystemService
) {}
ngOnInit(): void {
@@ -56,7 +54,6 @@ export class NpcViewComponent implements OnInit {
});
}
if (this.campaignId) {
this.campaignSidebar.show(this.campaignId);
this.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
if (camp.gameSystemId) {
this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => {

View File

@@ -7,8 +7,9 @@ import { LucideAngularModule } from 'lucide-angular';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { LayoutService } from '../../../services/layout.service';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { LayoutService, GlobalItem } from '../../../services/layout.service';
import { Campaign } from '../../../services/campaign.model';
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
@@ -66,7 +67,21 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
this.chapterName = currentChapter?.name ?? '';
this.existingSceneCount = treeData.scenesByChapter[this.chapterId]?.length ?? 0;
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
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'
});
});
}
@@ -79,7 +94,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
order: this.existingSceneCount + 1,
icon: this.selectedIcon
}).subscribe({
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', created.id]),
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', created.id, 'edit']),
error: () => console.error('Erreur lors de la création de la scène')
});
}
@@ -89,9 +104,6 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
// 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.
this.layoutService.hide();
}
}

View File

@@ -9,18 +9,17 @@ import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service';
import { LayoutService, GlobalItem } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Scene, SceneBranch } from '../../../services/campaign.model';
import { Campaign, Scene, SceneBranch } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
import { ExpandableSectionComponent } from '../../../shared/expandable-section/expandable-section.component';
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
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.
@@ -76,8 +75,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
private npcService: NpcService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService
private pageTitleService: PageTitleService
) {
this.form = this.fb.group({
name: ['', Validators.required],
@@ -157,7 +155,21 @@ export class SceneEditComponent implements OnInit, OnDestroy {
enemies: scene.enemies ?? ''
});
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
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'
});
});
}
@@ -188,18 +200,10 @@ export class SceneEditComponent implements OnInit, OnDestroy {
}
delete(): void {
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({
next: () => this.router.navigate(['/campaigns', this.campaignId]),
error: () => console.error('Erreur lors de la suppression')
});
if (!confirm(`Supprimer la scène "${this.scene?.name}" ? Cette action est irréversible.`)) return;
this.campaignService.deleteScene(this.sceneId).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId]),
error: () => console.error('Erreur lors de la suppression')
});
}
@@ -232,9 +236,6 @@ export class SceneEditComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
// 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.
this.layoutService.hide();
}
}

View File

@@ -9,13 +9,12 @@ import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service';
import { LayoutService, GlobalItem } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Scene } from '../../../services/campaign.model';
import { Campaign, Scene } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
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).
@@ -50,8 +49,7 @@ export class SceneViewComponent implements OnInit, OnDestroy {
private npcService: NpcService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService
private pageTitleService: PageTitleService
) {}
ngOnInit(): void {
@@ -91,7 +89,20 @@ export class SceneViewComponent implements OnInit, OnDestroy {
this.availablePages = pages;
this.pageTitleService.set(scene.name);
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
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'
});
});
}
@@ -110,27 +121,16 @@ export class SceneViewComponent implements OnInit, OnDestroy {
deleteScene(): void {
if (!this.scene) return;
const scene = this.scene;
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({
next: () => this.router.navigate([
'/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId
]),
error: () => console.error('Erreur lors de la suppression de la scène')
});
if (!confirm(`Supprimer la scène "${scene.name}" ?\n\nCette action est irréversible.`)) return;
this.campaignService.deleteScene(scene.id!).subscribe({
next: () => this.router.navigate([
'/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId
]),
error: () => console.error('Erreur lors de la suppression de la scène')
});
}
ngOnDestroy(): void {
// 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.
this.layoutService.hide();
}
}

View File

@@ -14,18 +14,18 @@
<div class="gse-form">
<div class="field">
<label for="gs-name">Nom *</label>
<input id="gs-name" type="text" [(ngModel)]="name" name="name" placeholder="Ex: Nimble, D&D 5.1 SRD, Mon Homebrew..." />
<label>Nom *</label>
<input type="text" [(ngModel)]="name" name="name" placeholder="Ex: Nimble, D&D 5.1 SRD, Mon Homebrew..." />
</div>
<div class="field">
<label for="gs-description">Description courte</label>
<textarea id="gs-description" [(ngModel)]="description" name="description" rows="2" placeholder="En une ligne, de quoi parle ce système ?"></textarea>
<label>Description courte</label>
<textarea [(ngModel)]="description" name="description" rows="2" placeholder="En une ligne, de quoi parle ce système ?"></textarea>
</div>
<div class="field">
<label for="gs-author">Auteur</label>
<input id="gs-author" type="text" [(ngModel)]="author" name="author" placeholder="Ex: Hasbro, Homebrew, moi-même..." />
<label>Auteur</label>
<input type="text" [(ngModel)]="author" name="author" placeholder="Ex: Hasbro, Homebrew, moi-même..." />
</div>
<!-- Sections de règles -->

View File

@@ -4,7 +4,6 @@ import { Router } from '@angular/router';
import { LucideAngularModule, Dices, Plus, Pencil, Trash2 } from 'lucide-angular';
import { GameSystemService } from '../services/game-system.service';
import { GameSystem } from '../services/game-system.model';
import { ConfirmDialogService } from '../shared/confirm-dialog/confirm-dialog.service';
@Component({
selector: 'app-game-systems',
@@ -23,8 +22,7 @@ export class GameSystemsComponent implements OnInit {
constructor(
private router: Router,
private gameSystemService: GameSystemService,
private confirmDialog: ConfirmDialogService
private gameSystemService: GameSystemService
) {}
ngOnInit(): void {
@@ -49,18 +47,10 @@ export class GameSystemsComponent implements OnInit {
delete(system: GameSystem, event: MouseEvent): void {
event.stopPropagation();
if (!system.id) 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({
next: () => this.load(),
error: () => console.error('Erreur suppression GameSystem')
});
if (!confirm(`Supprimer le système "${system.name}" ? Les campagnes qui l'utilisent ne seront plus associées à aucun système.`)) return;
this.gameSystemService.delete(system.id).subscribe({
next: () => this.load(),
error: () => console.error('Erreur suppression GameSystem')
});
}
}

View File

@@ -12,7 +12,6 @@ import { Lore, LoreNode } from '../../services/lore.model';
import { Page } from '../../services/page.model';
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
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
@@ -53,8 +52,7 @@ export class FolderViewComponent implements OnInit, OnDestroy {
private templateService: TemplateService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService
private pageTitleService: PageTitleService
) {}
ngOnInit(): void {
@@ -150,31 +148,25 @@ export class FolderViewComponent implements OnInit, OnDestroy {
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' : ''}`);
const details: string[] = [];
const lines = [`Supprimer le dossier "${node.name}" ?`];
if (parts.length) {
details.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
lines.push('');
lines.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
}
details.push('Cette action est irréversible.');
lines.push('');
lines.push('Cette action est irréversible.');
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({
next: () => {
// Remonte au dossier parent si présent, sinon au Lore.
if (node.parentId) {
this.router.navigate(['/lore', this.loreId, 'folders', node.parentId]);
} else {
this.router.navigate(['/lore', this.loreId]);
}
},
error: () => console.error('Erreur lors de la suppression du dossier')
});
if (!confirm(lines.join('\n'))) return;
this.loreService.deleteLoreNode(this.folderId).subscribe({
next: () => {
// Remonte au dossier parent si présent, sinon au Lore.
if (node.parentId) {
this.router.navigate(['/lore', this.loreId, 'folders', node.parentId]);
} else {
this.router.navigate(['/lore', this.loreId]);
}
},
error: () => console.error('Erreur lors de la suppression du dossier')
});
},
error: () => console.error('Impossible de récupérer les dépendances du dossier')
@@ -182,9 +174,6 @@ export class FolderViewComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
// 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.
this.layoutService.hide();
}
}

View File

@@ -10,7 +10,6 @@ import { LayoutService } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { Lore, LoreNode } from '../../services/lore.model';
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
@Component({
selector: 'app-lore-detail',
@@ -43,8 +42,7 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
private templateService: TemplateService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService
private pageTitleService: PageTitleService
) {}
ngOnInit(): void {
@@ -127,30 +125,25 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
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' : ''}`);
const details: string[] = [];
const lines = [`Supprimer définitivement le Lore "${lore.name}" ?`];
if (deleted.length) {
details.push(`Cette action supprimera aussi : ${deleted.join(', ')}.`);
lines.push('');
lines.push(`Cette action supprimera aussi : ${deleted.join(', ')}.`);
}
if (impact.detachedCampaigns > 0) {
details.push(
lines.push('');
lines.push(
`${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.`
);
}
details.push('Cette action est irréversible.');
lines.push('');
lines.push('Cette action est irréversible.');
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({
next: () => this.router.navigate(['/lore']),
error: () => console.error('Erreur lors de la suppression du Lore')
});
if (!confirm(lines.join('\n'))) return;
this.loreService.deleteLore(lore.id!).subscribe({
next: () => this.router.navigate(['/lore']),
error: () => console.error('Erreur lors de la suppression du Lore')
});
},
error: () => console.error('Impossible de récupérer les dépendances du Lore')

View File

@@ -111,9 +111,6 @@ export class LoreNodeCreateComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
// 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.
this.layoutService.hide();
}
}

View File

@@ -147,9 +147,6 @@ export class LoreNodeEditComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
// 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.
this.layoutService.hide();
}
}

View File

@@ -30,23 +30,6 @@
</div>
<p class="template-description">{{ t.description || '—' }}</p>
</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>
<ng-template #emptyTemplates>

View File

@@ -116,27 +116,6 @@
}
}
// 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 {
background: #1a1a2e;
border: 1px solid #2a2a3d;

View File

@@ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { LucideAngularModule, FileText, Sparkles, Plus } from 'lucide-angular';
import { LucideAngularModule, FileText, Sparkles } from 'lucide-angular';
import { LoreService } from '../../services/lore.service';
import { TemplateService } from '../../services/template.service';
import { PageService } from '../../services/page.service';
@@ -34,7 +34,6 @@ import { AiChatDrawerComponent, ChatPrimaryAction } from '../../shared/ai-chat-d
export class PageCreateComponent implements OnInit, OnDestroy {
readonly FileText = FileText;
readonly Sparkles = Sparkles;
readonly Plus = Plus;
form: FormGroup;
loreId = '';
@@ -118,22 +117,6 @@ export class PageCreateComponent implements OnInit, OnDestroy {
});
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
});
}
});
}
@@ -339,9 +322,6 @@ Les clés du JSON doivent correspondre EXACTEMENT aux noms de champs indiqués.
}
ngOnDestroy(): void {
// 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.
this.layoutService.hide();
}
}

View File

@@ -19,7 +19,6 @@ import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/bre
import { AiChatDrawerComponent, ChatPrimaryAction } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
import { Lore } from '../../services/lore.model';
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
/**
* Écran d'édition d'une Page.
@@ -91,8 +90,7 @@ export class PageEditComponent implements OnInit, OnDestroy {
private templateService: TemplateService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService
private pageTitleService: PageTitleService
) {}
ngOnInit(): void {
@@ -260,24 +258,14 @@ export class PageEditComponent implements OnInit, OnDestroy {
delete(): void {
if (!this.page) 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({
next: () => this.router.navigate(['/lore', this.loreId]),
error: () => console.error('Erreur lors de la suppression de la page')
});
if (!confirm(`Supprimer la page "${this.page.title}" ?`)) return;
this.pageService.delete(this.pageId).subscribe({
next: () => this.router.navigate(['/lore', this.loreId]),
error: () => console.error('Erreur lors de la suppression de la page')
});
}
ngOnDestroy(): void {
// 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.
this.layoutService.hide();
}
}

View File

@@ -14,7 +14,6 @@ import { Page } from '../../services/page.model';
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.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).
@@ -52,8 +51,7 @@ export class PageViewComponent implements OnInit, OnDestroy {
private templateService: TemplateService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService
private pageTitleService: PageTitleService
) {}
ngOnInit(): void {
@@ -131,31 +129,20 @@ export class PageViewComponent implements OnInit, OnDestroy {
deletePage(): void {
if (!this.page) return;
const page = this.page;
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({
next: () => {
if (page.nodeId) {
this.router.navigate(['/lore', this.loreId, 'folders', page.nodeId]);
} else {
this.router.navigate(['/lore', this.loreId]);
}
},
error: () => console.error('Erreur lors de la suppression de la page')
});
if (!confirm(`Supprimer la page "${page.title}" ?\n\nCette action est irréversible.`)) return;
this.pageService.delete(page.id!).subscribe({
next: () => {
if (page.nodeId) {
this.router.navigate(['/lore', this.loreId, 'folders', page.nodeId]);
} else {
this.router.navigate(['/lore', this.loreId]);
}
},
error: () => console.error('Erreur lors de la suppression de la page')
});
}
ngOnDestroy(): void {
// 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.
this.layoutService.hide();
}
}

View File

@@ -176,40 +176,32 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
defaultNodeId: raw.defaultNodeId,
fields: this.fields
}).subscribe({
next: (created) => this.navigateBack(created.id ?? null),
next: () => this.navigateBack(),
error: () => console.error('Erreur lors de la création du template')
});
}
cancel(): void {
this.navigateBack(null);
this.navigateBack();
}
/**
* 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
* `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(createdTemplateId: string | null): void {
private navigateBack(): void {
const { next, rest } = popReturnTo(this.route.snapshot.queryParamMap.get('returnTo'));
if (next === 'page-create') {
const queryParams: Record<string, string> = {};
if (rest) queryParams['returnTo'] = rest;
if (createdTemplateId) queryParams['selectTemplateId'] = createdTemplateId;
this.router.navigate(['/lore', this.loreId, 'pages', 'create'], { queryParams });
this.router.navigate(['/lore', this.loreId, 'pages', 'create'], {
queryParams: rest ? { returnTo: rest } : {}
});
return;
}
this.router.navigate(['/lore', this.loreId]);
}
ngOnDestroy(): void {
// 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.
this.layoutService.hide();
}
}

View File

@@ -2,8 +2,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin, Subject } from 'rxjs';
import { switchMap, takeUntil } from 'rxjs/operators';
import { forkJoin } from 'rxjs';
import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown } from 'lucide-angular';
import { LoreService } from '../../services/lore.service';
import { TemplateService } from '../../services/template.service';
@@ -13,7 +12,6 @@ import { PageTitleService } from '../../services/page-title.service';
import { LoreNode } from '../../services/lore.model';
import { FieldType, ImageLayout, Template, TemplateField } from '../../services/template.model';
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
/**
* Écran d'édition d'un Template existant.
@@ -49,8 +47,6 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
*/
private originalFieldNames = new Set<string>();
private destroy$ = new Subject<void>();
/** True si le champ est présent depuis le chargement du template. */
isExistingField(field: TemplateField): boolean {
return this.originalFieldNames.has(field.name);
@@ -64,8 +60,7 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
private templateService: TemplateService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService
private pageTitleService: PageTitleService
) {
this.form = this.fb.group({
name: ['', Validators.required],
@@ -75,21 +70,13 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
}
ngOnInit(): void {
// switchMap pour annuler le chargement precedent si l'utilisateur change
// de template avant la fin de la requete (Angular reutilise l'instance du
// composant entre /templates/T1 et /templates/T2, donc ngOnInit ne refire
// 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),
template: this.templateService.getById(this.templateId)
});
}),
takeUntil(this.destroy$)
).subscribe(({ sidebar, template }) => {
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
this.templateId = this.route.snapshot.paramMap.get('templateId')!;
forkJoin({
sidebar: loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService),
template: this.templateService.getById(this.templateId)
}).subscribe(({ sidebar, template }) => {
this.nodes = sidebar.nodes;
this.layoutService.show(buildLoreSidebarConfig(sidebar));
this.hydrate(template);
@@ -175,25 +162,14 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
}
delete(): void {
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({
next: () => this.router.navigate(['/lore', this.loreId]),
error: () => console.error('Erreur lors de la suppression du template')
});
if (!confirm(`Supprimer le template "${this.template?.name}" ?`)) return;
this.templateService.delete(this.templateId).subscribe({
next: () => this.router.navigate(['/lore', this.loreId]),
error: () => console.error('Erreur lors de la suppression du template')
});
}
ngOnDestroy(): void {
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.
this.layoutService.hide();
}
}

View File

@@ -1,51 +0,0 @@
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));
});
}
}

View File

@@ -8,7 +8,6 @@ import { Subscription } from 'rxjs';
import { UpdatesService, UpdateStatus } from '../services/updates.service';
import { ConfigService } from '../services/config.service';
import { LicenseService, LicenseStatusDTO, BetaStatusDTO } from '../services/license.service';
import { ConfirmDialogService } from '../shared/confirm-dialog/confirm-dialog.service';
/**
* Ecran de parametrage du LLM utilise par le Brain.
@@ -121,8 +120,7 @@ export class SettingsComponent implements OnInit {
private router: Router,
private updatesService: UpdatesService,
public config: ConfigService,
private licenseService: LicenseService,
private confirmDialog: ConfirmDialogService
private licenseService: LicenseService
) {}
ngOnInit(): void {
@@ -199,20 +197,12 @@ export class SettingsComponent implements OnInit {
}
disconnectPatreon(): void {
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.licenseStatus = null;
this.betaStatus = null;
this.successMessage = 'Compte Patreon deconnecte.';
this.loadLicense();
});
if (!confirm('Deconnecter ton compte Patreon ? Tu perdras l\'acces au canal beta.')) return;
this.licenseService.disconnect().subscribe(() => {
this.licenseStatus = null;
this.betaStatus = null;
this.successMessage = 'Compte Patreon deconnecte.';
this.loadLicense();
});
}
@@ -266,29 +256,23 @@ export class SettingsComponent implements OnInit {
}
applyUpdate(): void {
this.confirmDialog.confirm({
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.updateMessage = '';
this.updatesService.apply().subscribe({
next: (r) => {
this.updateApplying = false;
// Le redemarrage de core peut couper la connexion avant la reponse —
// dans ce cas r vaut null (gere par catchError dans le service).
this.updateMessage = r?.message
?? 'Mise a jour declenchee. Rechargez la page dans 30s.';
},
error: () => {
this.updateApplying = false;
this.updateMessage = 'Mise a jour declenchee. Rechargez la page dans 30s.';
}
});
if (!confirm('Telecharger et redemarrer les conteneurs maintenant ? L\'app sera indisponible quelques secondes.')) {
return;
}
this.updateApplying = true;
this.updateMessage = '';
this.updatesService.apply().subscribe({
next: (r) => {
this.updateApplying = false;
// Le redemarrage de core peut couper la connexion avant la reponse —
// dans ce cas r vaut null (gere par catchError dans le service).
this.updateMessage = r?.message
?? 'Mise a jour declenchee. Rechargez la page dans 30s.';
},
error: () => {
this.updateApplying = false;
this.updateMessage = 'Mise a jour declenchee. Rechargez la page dans 30s.';
}
});
}
@@ -507,33 +491,25 @@ export class SettingsComponent implements OnInit {
}
deleteModel(name: string): void {
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.errorMessage = '';
this.settingsService.deleteOllamaModel(name).subscribe({
next: () => {
this.deletingModel = null;
this.successMessage = `Modele ${name} supprime.`;
// Si l'utilisateur supprime le modele actuellement selectionne,
// on bascule sur le premier disponible (ou vide).
this.refreshModels();
if (this.settings && this.settings.llm_model === name) {
this.settings.llm_model = '';
this.ollamaModelMaxContext = 0;
}
},
error: (err) => {
this.deletingModel = null;
this.errorMessage = this.extractError(err, `Echec de la suppression de ${name}.`);
if (!confirm(`Supprimer le modele '${name}' ? L'espace disque sera libere.`)) return;
this.deletingModel = name;
this.errorMessage = '';
this.settingsService.deleteOllamaModel(name).subscribe({
next: () => {
this.deletingModel = null;
this.successMessage = `Modele ${name} supprime.`;
// Si l'utilisateur supprime le modele actuellement selectionne,
// on bascule sur le premier disponible (ou vide).
this.refreshModels();
if (this.settings && this.settings.llm_model === name) {
this.settings.llm_model = '';
this.ollamaModelMaxContext = 0;
}
});
},
error: (err) => {
this.deletingModel = null;
this.errorMessage = this.extractError(err, `Echec de la suppression de ${name}.`);
}
});
}

View File

@@ -7,7 +7,6 @@ import { AiChatService, ChatMessage, ChatUsage, NarrativeEntityType } from '../.
import { Conversation, ConversationContext } from '../../services/conversation.model';
import { ConversationService } from '../../services/conversation.service';
import { MarkdownPipe } from '../markdown.pipe';
import { ConfirmDialogService } from '../confirm-dialog/confirm-dialog.service';
/**
* Action primaire optionnelle rendue en gros bouton au-dessus des suggestions.
@@ -120,7 +119,6 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
constructor(
private readonly chatService: AiChatService,
private readonly conversationService: ConversationService,
private readonly confirmDialog: ConfirmDialogService,
) {}
// --- Jauge de contexte --------------------------------------------------
@@ -314,19 +312,12 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
deleteConversation(conv: Conversation, event: Event): void {
event.stopPropagation();
if (this.isStreaming) 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({
next: () => {
this.conversations = this.conversations.filter((c) => c.id !== conv.id);
if (this.currentConversationId === conv.id) this.resetConversationState();
},
});
if (!confirm(`Supprimer la conversation "${conv.title}" ?`)) return;
this.conversationService.delete(conv.id).subscribe({
next: () => {
this.conversations = this.conversations.filter((c) => c.id !== conv.id);
if (this.currentConversationId === conv.id) this.resetConversationState();
},
});
}

View File

@@ -1,27 +0,0 @@
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) {}
}

View File

@@ -1,34 +0,0 @@
<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>

View File

@@ -1,127 +0,0 @@
.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; } }
}

View File

@@ -1,31 +0,0 @@
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(); }
}

View File

@@ -1,59 +0,0 @@
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);
}
}

View File

@@ -34,7 +34,7 @@
<section *ngIf="s.kind === 'TEXT'" class="pv-section">
<h2 class="pv-section-title">{{ s.name }}</h2>
<div class="pv-section-body">
<p class="pv-paragraph">
<p [class.with-dropcap]="s.name === firstTextSectionName" class="pv-paragraph">
{{ firstParagraph(s.value) }}
</p>
<p *ngIf="restAfterFirstParagraph(s.value)" class="pv-paragraph">

View File

@@ -290,6 +290,17 @@
.pv-paragraph {
margin: 0 0 14px;
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 --------------------------------------------------------------

View File

@@ -111,7 +111,15 @@ export class PersonaViewComponent {
return this.rendered().sections;
}
/** Premier paragraphe d'un texte (separe pour permettre un styling specifique). */
/** Pour la drop cap : seul le 1er TEXT la recoit. */
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 {
if (!text) return '';
const paragraphs = text.split(/\n\s*\n/);