8 Commits

Author SHA1 Message Date
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
102 changed files with 2833 additions and 236 deletions

95
.gitea/workflows/e2e.yml Normal file
View File

@@ -0,0 +1,95 @@
name: E2E Tests
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Create .env for stack
run: |
cat > .env <<'EOF'
POSTGRES_PASSWORD=ci-postgres-pass
BRAIN_INTERNAL_SECRET=ci-brain-secret
ADMIN_USERNAME=admin
ADMIN_PASSWORD=ci-admin-pass
WEB_PORT=8081
LLM_PROVIDER=ollama
EOF
- name: Build & start stack
run: |
docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d --build
- name: Attach runner to compose network
run: |
NET=$(docker network ls --format '{{.Name}}' | grep -E '(^|_)loremind(_|$)' | grep -i default | head -1)
if [ -z "$NET" ]; then
echo "Compose network not found" >&2
docker network ls
exit 1
fi
echo "Connecting $(hostname) to network $NET"
docker network connect "$NET" "$(hostname)"
- name: Wait for web to be ready
run: |
timeout 180 bash -c 'until curl -sf http://web/ > /dev/null; do echo "waiting..."; sleep 3; done'
- name: Install web deps
working-directory: web
run: npm ci
- name: Work around runner clock skew for apt
run: |
sudo tee /etc/apt/apt.conf.d/99no-check-valid-until >/dev/null <<'EOF'
Acquire::Check-Valid-Until "false";
Acquire::Check-Date "false";
EOF
- name: Install Playwright browsers
working-directory: web
run: npx playwright install --with-deps chromium
- name: Run Playwright tests
working-directory: web
env:
E2E_BASE_URL: http://web
CI: 'true'
run: npm run e2e
- name: Dump container logs on failure
if: failure()
run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml logs --no-color
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: web/playwright-report/
retention-days: 14
- name: Stop stack
if: always()
run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml down -v

6
.gitignore vendored
View File

@@ -53,6 +53,12 @@ yarn-error.log*
.pnpm-debug.log*
coverage/
# Playwright (E2E)
web/test-results/
web/playwright-report/
web/blob-report/
web/playwright/.cache/
# ============================================================================
# IDE / Editeurs
# ============================================================================

View File

@@ -40,7 +40,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.6.1",
version="0.6.5",
)

View File

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

View File

@@ -36,11 +36,16 @@ public class ArcService {
public record DeletionImpact(int chapters, int scenes) {}
public Arc createArc(String name, String description, String campaignId, int order) {
return createArc(name, description, campaignId, order, null);
}
public Arc createArc(String name, String description, String campaignId, int order, String icon) {
Arc arc = Arc.builder()
.name(name)
.description(description)
.campaignId(campaignId)
.order(order)
.icon(icon)
.build();
return arcRepository.save(arc);
}

View File

@@ -30,11 +30,16 @@ public class ChapterService {
public record DeletionImpact(int scenes) {}
public Chapter createChapter(String name, String description, String arcId, int order) {
return createChapter(name, description, arcId, order, null);
}
public Chapter createChapter(String name, String description, String arcId, int order, String icon) {
Chapter chapter = Chapter.builder()
.name(name)
.description(description)
.arcId(arcId)
.order(order)
.icon(icon)
.build();
return chapterRepository.save(chapter);
}

View File

@@ -26,11 +26,16 @@ public class SceneService {
}
public Scene createScene(String name, String description, String chapterId, int order) {
return createScene(name, description, chapterId, order, null);
}
public Scene createScene(String name, String description, String chapterId, int order, String icon) {
Scene scene = Scene.builder()
.name(name)
.description(description)
.chapterId(chapterId)
.order(order)
.icon(icon)
.build();
return sceneRepository.save(scene);
}

View File

@@ -21,6 +21,9 @@ public class Arc {
private String campaignId; // Référence vers la Campaign parente
private int order; // Ordre de l'arc dans la campagne
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// Champs narratifs enrichis (voir docs/maquettes/campagne/détail/)
private String themes; // Thèmes principaux explorés dans cet arc
private String stakes; // Enjeux globaux pour les personnages

View File

@@ -21,6 +21,9 @@ public class Chapter {
private String arcId; // Référence vers l'Arc parent
private int order; // Ordre du chapitre dans l'arc
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// Champs narratifs enrichis (voir docs/maquettes/campagne/détail/)
private String gmNotes; // Notes privées du MJ (non exportées vers FoundryVTT)
private String playerObjectives; // Objectifs des joueurs dans ce chapitre

View File

@@ -21,6 +21,9 @@ public class Scene {
private String chapterId; // Référence vers le Chapter parent
private int order; // Ordre de la scène dans le chapitre
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// === Contexte et ambiance ===
private String location; // Lieu de la scène (ex: Taverne du Dragon d'Or)
private String timing; // Moment (ex: Soir, à la tombée de la nuit)

View File

@@ -37,6 +37,9 @@ public class ArcJpaEntity {
@Column(name = "\"order\"", nullable = false)
private int order;
@Column
private String icon;
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update)
@Column(columnDefinition = "TEXT")
private String themes;

View File

@@ -37,6 +37,9 @@ public class ChapterJpaEntity {
@Column(name = "\"order\"", nullable = false)
private int order;
@Column
private String icon;
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update)
@Column(name = "gm_notes", columnDefinition = "TEXT")
private String gmNotes;

View File

@@ -39,6 +39,9 @@ public class SceneJpaEntity {
@Column(name = "\"order\"", nullable = false)
private int order;
@Column
private String icon;
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate (ddl-auto=update)
// Contexte et ambiance

View File

@@ -71,6 +71,7 @@ public class PostgresArcRepository implements ArcRepository {
.description(jpaEntity.getDescription())
.campaignId(jpaEntity.getCampaignId().toString())
.order(jpaEntity.getOrder())
.icon(jpaEntity.getIcon())
.themes(jpaEntity.getThemes())
.stakes(jpaEntity.getStakes())
.gmNotes(jpaEntity.getGmNotes())
@@ -99,6 +100,7 @@ public class PostgresArcRepository implements ArcRepository {
.description(arc.getDescription())
.campaignId(Long.parseLong(arc.getCampaignId()))
.order(arc.getOrder())
.icon(arc.getIcon())
.themes(arc.getThemes())
.stakes(arc.getStakes())
.gmNotes(arc.getGmNotes())

View File

@@ -71,6 +71,7 @@ public class PostgresChapterRepository implements ChapterRepository {
.description(jpaEntity.getDescription())
.arcId(jpaEntity.getArcId().toString())
.order(jpaEntity.getOrder())
.icon(jpaEntity.getIcon())
.gmNotes(jpaEntity.getGmNotes())
.playerObjectives(jpaEntity.getPlayerObjectives())
.narrativeStakes(jpaEntity.getNarrativeStakes())
@@ -96,6 +97,7 @@ public class PostgresChapterRepository implements ChapterRepository {
.description(chapter.getDescription())
.arcId(Long.parseLong(chapter.getArcId()))
.order(chapter.getOrder())
.icon(chapter.getIcon())
.gmNotes(chapter.getGmNotes())
.playerObjectives(chapter.getPlayerObjectives())
.narrativeStakes(chapter.getNarrativeStakes())

View File

@@ -71,6 +71,7 @@ public class PostgresSceneRepository implements SceneRepository {
.description(jpaEntity.getDescription())
.chapterId(jpaEntity.getChapterId().toString())
.order(jpaEntity.getOrder())
.icon(jpaEntity.getIcon())
.location(jpaEntity.getLocation())
.timing(jpaEntity.getTiming())
.atmosphere(jpaEntity.getAtmosphere())
@@ -104,6 +105,7 @@ public class PostgresSceneRepository implements SceneRepository {
.description(scene.getDescription())
.chapterId(Long.parseLong(scene.getChapterId()))
.order(scene.getOrder())
.icon(scene.getIcon())
.location(scene.getLocation())
.timing(scene.getTiming())
.atmosphere(scene.getAtmosphere())

View File

@@ -28,7 +28,7 @@ public class ArcController {
@PostMapping
public ResponseEntity<ArcDTO> createArc(@RequestBody ArcDTO arcDTO) {
Arc arc = arcMapper.toDomain(arcDTO);
Arc createdArc = arcService.createArc(arc.getName(), arc.getDescription(), arc.getCampaignId(), arc.getOrder());
Arc createdArc = arcService.createArc(arc.getName(), arc.getDescription(), arc.getCampaignId(), arc.getOrder(), arc.getIcon());
return ResponseEntity.ok(arcMapper.toDTO(createdArc));
}
@@ -40,17 +40,11 @@ public class ArcController {
}
@GetMapping
public ResponseEntity<List<ArcDTO>> getAllArcs() {
List<Arc> arcs = arcService.getAllArcs();
List<ArcDTO> arcDTOs = arcs.stream()
.map(arcMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(arcDTOs);
}
@GetMapping("/campaign/{campaignId}")
public ResponseEntity<List<ArcDTO>> getArcsByCampaignId(@PathVariable String campaignId) {
List<Arc> arcs = arcService.getArcsByCampaignId(campaignId);
public ResponseEntity<List<ArcDTO>> getAllArcs(
@RequestParam(value = "campaignId", required = false) String campaignId) {
List<Arc> arcs = (campaignId != null && !campaignId.isBlank())
? arcService.getArcsByCampaignId(campaignId)
: arcService.getAllArcs();
List<ArcDTO> arcDTOs = arcs.stream()
.map(arcMapper::toDTO)
.collect(Collectors.toList());

View File

@@ -28,7 +28,7 @@ public class ChapterController {
@PostMapping
public ResponseEntity<ChapterDTO> createChapter(@RequestBody ChapterDTO chapterDTO) {
Chapter chapter = chapterMapper.toDomain(chapterDTO);
Chapter createdChapter = chapterService.createChapter(chapter.getName(), chapter.getDescription(), chapter.getArcId(), chapter.getOrder());
Chapter createdChapter = chapterService.createChapter(chapter.getName(), chapter.getDescription(), chapter.getArcId(), chapter.getOrder(), chapter.getIcon());
return ResponseEntity.ok(chapterMapper.toDTO(createdChapter));
}
@@ -40,17 +40,11 @@ public class ChapterController {
}
@GetMapping
public ResponseEntity<List<ChapterDTO>> getAllChapters() {
List<Chapter> chapters = chapterService.getAllChapters();
List<ChapterDTO> chapterDTOs = chapters.stream()
.map(chapterMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(chapterDTOs);
}
@GetMapping("/arc/{arcId}")
public ResponseEntity<List<ChapterDTO>> getChaptersByArcId(@PathVariable String arcId) {
List<Chapter> chapters = chapterService.getChaptersByArcId(arcId);
public ResponseEntity<List<ChapterDTO>> getAllChapters(
@RequestParam(value = "arcId", required = false) String arcId) {
List<Chapter> chapters = (arcId != null && !arcId.isBlank())
? chapterService.getChaptersByArcId(arcId)
: chapterService.getAllChapters();
List<ChapterDTO> chapterDTOs = chapters.stream()
.map(chapterMapper::toDTO)
.collect(Collectors.toList());

View File

@@ -28,7 +28,7 @@ public class SceneController {
@PostMapping
public ResponseEntity<SceneDTO> createScene(@RequestBody SceneDTO sceneDTO) {
Scene scene = sceneMapper.toDomain(sceneDTO);
Scene createdScene = sceneService.createScene(scene.getName(), scene.getDescription(), scene.getChapterId(), scene.getOrder());
Scene createdScene = sceneService.createScene(scene.getName(), scene.getDescription(), scene.getChapterId(), scene.getOrder(), scene.getIcon());
return ResponseEntity.ok(sceneMapper.toDTO(createdScene));
}
@@ -40,17 +40,11 @@ public class SceneController {
}
@GetMapping
public ResponseEntity<List<SceneDTO>> getAllScenes() {
List<Scene> scenes = sceneService.getAllScenes();
List<SceneDTO> sceneDTOs = scenes.stream()
.map(sceneMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(sceneDTOs);
}
@GetMapping("/chapter/{chapterId}")
public ResponseEntity<List<SceneDTO>> getScenesByChapterId(@PathVariable String chapterId) {
List<Scene> scenes = sceneService.getScenesByChapterId(chapterId);
public ResponseEntity<List<SceneDTO>> getAllScenes(
@RequestParam(value = "chapterId", required = false) String chapterId) {
List<Scene> scenes = (chapterId != null && !chapterId.isBlank())
? sceneService.getScenesByChapterId(chapterId)
: sceneService.getAllScenes();
List<SceneDTO> sceneDTOs = scenes.stream()
.map(sceneMapper::toDTO)
.collect(Collectors.toList());

View File

@@ -17,6 +17,9 @@ public class ArcDTO {
private String campaignId;
private int order;
/** Cle d'icone (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// Champs narratifs enrichis
private String themes;
private String stakes;

View File

@@ -17,6 +17,9 @@ public class ChapterDTO {
private String arcId;
private int order;
/** Cle d'icone (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// Champs narratifs enrichis
private String gmNotes;
private String playerObjectives;

View File

@@ -17,6 +17,9 @@ public class SceneDTO {
private String chapterId;
private int order;
/** Cle d'icone (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// Champs narratifs enrichis
private String location;
private String timing;

View File

@@ -24,6 +24,7 @@ public class ArcMapper {
dto.setDescription(arc.getDescription());
dto.setCampaignId(arc.getCampaignId());
dto.setOrder(arc.getOrder());
dto.setIcon(arc.getIcon());
dto.setThemes(arc.getThemes());
dto.setStakes(arc.getStakes());
dto.setGmNotes(arc.getGmNotes());
@@ -46,6 +47,7 @@ public class ArcMapper {
.description(dto.getDescription())
.campaignId(dto.getCampaignId())
.order(dto.getOrder())
.icon(dto.getIcon())
.themes(dto.getThemes())
.stakes(dto.getStakes())
.gmNotes(dto.getGmNotes())

View File

@@ -24,6 +24,7 @@ public class ChapterMapper {
dto.setDescription(chapter.getDescription());
dto.setArcId(chapter.getArcId());
dto.setOrder(chapter.getOrder());
dto.setIcon(chapter.getIcon());
dto.setGmNotes(chapter.getGmNotes());
dto.setPlayerObjectives(chapter.getPlayerObjectives());
dto.setNarrativeStakes(chapter.getNarrativeStakes());
@@ -44,6 +45,7 @@ public class ChapterMapper {
.description(dto.getDescription())
.arcId(dto.getArcId())
.order(dto.getOrder())
.icon(dto.getIcon())
.gmNotes(dto.getGmNotes())
.playerObjectives(dto.getPlayerObjectives())
.narrativeStakes(dto.getNarrativeStakes())

View File

@@ -27,6 +27,7 @@ public class SceneMapper {
dto.setDescription(scene.getDescription());
dto.setChapterId(scene.getChapterId());
dto.setOrder(scene.getOrder());
dto.setIcon(scene.getIcon());
dto.setLocation(scene.getLocation());
dto.setTiming(scene.getTiming());
dto.setAtmosphere(scene.getAtmosphere());
@@ -59,6 +60,7 @@ public class SceneMapper {
.description(dto.getDescription())
.chapterId(dto.getChapterId())
.order(dto.getOrder())
.icon(dto.getIcon())
.location(dto.getLocation())
.timing(dto.getTiming())
.atmosphere(dto.getAtmosphere())

View File

@@ -79,7 +79,7 @@ class ArcControllerTest {
@Test
void getByCampaign_pathVariant() throws Exception {
arcRepository.save(Arc.builder().campaignId(campaignId).name("A").order(0).build());
mockMvc.perform(get("/api/arcs/campaign/{id}", campaignId))
mockMvc.perform(get("/api/arcs").param("campaignId", campaignId))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}

View File

@@ -81,7 +81,7 @@ class ChapterControllerTest {
@Test
void getByArc_pathVariant() throws Exception {
chapterRepository.save(Chapter.builder().arcId(arcId).name("A").order(0).build());
mockMvc.perform(get("/api/chapters/arc/{id}", arcId))
mockMvc.perform(get("/api/chapters").param("arcId", arcId))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}

View File

@@ -85,7 +85,7 @@ class SceneControllerTest {
@Test
void getByChapter_pathVariant() throws Exception {
sceneRepository.save(Scene.builder().chapterId(chapterId).name("A").order(0).build());
mockMvc.perform(get("/api/scenes/chapter/{id}", chapterId))
mockMvc.perform(get("/api/scenes").param("chapterId", chapterId))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}

View File

@@ -51,6 +51,8 @@ services:
BRAIN_INTERNAL_SECRET_DEFAULT: ${BRAIN_INTERNAL_SECRET_DEFAULT:-change-me}
# Rate limit : 1 creation par IP par fenetre (en secondes).
RATE_LIMIT_WINDOW_SECONDS: ${RATE_LIMIT_WINDOW_SECONDS:-60}
# Domaine public : propage aux cores de session pour configurer CORS.
DEMO_HOST: ${DEMO_HOST:-loremind-demo.igmlcreation.fr}
networks:
- traefik
- sessions

View File

@@ -22,6 +22,7 @@ type Config struct {
PreparingPage string
RateLimitWindow time.Duration
MaxBodyBytes int64
DemoHost string
}
func loadConfig() *Config {
@@ -40,6 +41,9 @@ func loadConfig() *Config {
RateLimitWindow: time.Duration(envInt("RATE_LIMIT_WINDOW_SECONDS", 60)) * time.Second,
// 10 Mo : aligne avec la limite d'upload d'image cote core.
MaxBodyBytes: int64(envInt("MAX_BODY_MB", 10)) * 1024 * 1024,
// Utilise pour injecter APP_CORS_ALLOWED_ORIGINS dans les cores spawnes :
// sans ca, Spring bloque les POST avec 403 (origine rejetee).
DemoHost: envStr("DEMO_HOST", "loremind-demo.igmlcreation.fr"),
}
}

View File

@@ -139,7 +139,11 @@ func (d *DockerClient) SpawnTrio(ctx context.Context, sessionID string, cfg *Con
"ADMIN_USERNAME=admin",
"ADMIN_PASSWORD=" + adminPassword,
"DEMO_MODE=true",
"CORS_ALLOWED_ORIGINS=*",
// CorsConfig.java lit app.cors.allowed-origins (= APP_CORS_ALLOWED_ORIGINS
// via le relaxed binding Spring). Necessaire meme en same-origin car
// le browser envoie Origin sur les POST et le CorsFilter 403 les
// origines inconnues.
"APP_CORS_ALLOWED_ORIGINS=https://" + cfg.DemoHost,
},
Labels: copyLabels(labels, "core"),
Memory: cfg.CoreMemoryBytes,

View File

@@ -56,11 +56,18 @@ func (rl *rateLimiter) cleanupLoop() {
}
}
// clientIP extrait l'IP reelle en prenant la derniere entree de X-Forwarded-For.
// Justification : Traefik APPEND l'IP du peer au header existant, donc la
// derniere valeur est celle que Traefik a observe directement (le vrai client).
// Prendre la premiere serait une faille : un attaquant peut preremplir le header.
// clientIP extrait l'IP reelle du visiteur en tenant compte du setup reverse-proxy.
// Ordre de priorite :
// 1. CF-Connecting-IP : defini par Cloudflare sur la base de SA propre vue du
// peer TCP, non-forgeable par le client, ecrase toute valeur entrante.
// 2. X-Forwarded-For, derniere entree : quand seul Traefik est en front (pas
// de Cloudflare), Traefik append l'IP qu'il observe. Prendre la premiere
// serait une faille (header forgeable).
// 3. RemoteAddr : fallback si aucun header de proxy n'est present.
func clientIP(r *http.Request) string {
if cfIP := r.Header.Get("CF-Connecting-IP"); cfIP != "" {
return strings.TrimSpace(cfIP)
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
return strings.TrimSpace(parts[len(parts)-1])

17
docker-compose.e2e.yml Normal file
View File

@@ -0,0 +1,17 @@
# Override pour la CI E2E : build les images depuis les sources au lieu de les puller.
# Usage : docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d --build
services:
core:
build:
context: ./core
image: loremind-core:e2e
brain:
build:
context: ./brain
image: loremind-brain:e2e
web:
build:
context: ./web
image: loremind-web:e2e

317
web/e2e/fixtures/api.ts Normal file
View File

@@ -0,0 +1,317 @@
import { APIRequestContext, expect } from '@playwright/test';
export interface SeededLore {
id: string;
name: string;
rootFolderId: string;
rootFolderName: string;
}
/**
* Seed un Lore + un dossier racine via l'API backend.
* Les noms sont uniques (timestamp + random) pour éviter les collisions en parallèle.
*/
export async function seedLoreWithFolder(request: APIRequestContext): Promise<SeededLore> {
const suffix = `${Date.now()}-${Math.floor(Math.random() * 10000)}`;
const loreName = `E2E Lore ${suffix}`;
const folderName = `E2E Folder ${suffix}`;
const loreRes = await request.post('/api/lores', {
data: { name: loreName, description: 'Seeded by Playwright' },
});
expect(loreRes.ok(), `POST /api/lores -> ${loreRes.status()}`).toBeTruthy();
const lore = await loreRes.json();
const folderRes = await request.post('/api/lore-nodes', {
data: { loreId: lore.id, name: folderName, icon: 'folder', description: '' },
});
expect(folderRes.ok(), `POST /api/lore-nodes -> ${folderRes.status()}`).toBeTruthy();
const folder = await folderRes.json();
return { id: lore.id, name: loreName, rootFolderId: folder.id, rootFolderName: folderName };
}
/** Cleanup best-effort — n'échoue pas si déjà supprimé. */
export async function deleteLore(request: APIRequestContext, loreId: string): Promise<void> {
await request.delete(`/api/lores/${loreId}`).catch(() => undefined);
}
export async function getLoreById(
request: APIRequestContext,
loreId: string,
): Promise<{ id: string; name: string; description: string }> {
const res = await request.get(`/api/lores/${loreId}`);
expect(res.ok(), `GET /api/lores/${loreId} -> ${res.status()}`).toBeTruthy();
return res.json();
}
export async function getArcsForCampaign(
request: APIRequestContext,
campaignId: string,
): Promise<Array<{ id: string; name: string; campaignId: string }>> {
const res = await request.get(`/api/arcs?campaignId=${campaignId}`);
expect(res.ok(), `GET /api/arcs -> ${res.status()}`).toBeTruthy();
return res.json();
}
export async function getChaptersForArc(
request: APIRequestContext,
arcId: string,
): Promise<Array<{ id: string; name: string; arcId: string }>> {
const res = await request.get(`/api/chapters?arcId=${arcId}`);
expect(res.ok(), `GET /api/chapters -> ${res.status()}`).toBeTruthy();
return res.json();
}
export async function getScenesForChapter(
request: APIRequestContext,
chapterId: string,
): Promise<Array<{ id: string; name: string; chapterId: string }>> {
const res = await request.get(`/api/scenes?chapterId=${chapterId}`);
expect(res.ok(), `GET /api/scenes -> ${res.status()}`).toBeTruthy();
return res.json();
}
export async function getTemplatesForLore(
request: APIRequestContext,
loreId: string,
): Promise<Array<{ id: string; name: string }>> {
const res = await request.get(`/api/templates?loreId=${loreId}`);
expect(res.ok(), `GET /api/templates -> ${res.status()}`).toBeTruthy();
return res.json();
}
export interface SeededTemplate {
id: string;
name: string;
}
export async function seedTemplate(
request: APIRequestContext,
opts: { loreId: string; defaultNodeId: string; name?: string; fieldNames?: string[] },
): Promise<SeededTemplate> {
const templateName = opts.name ?? `E2E Template ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
const fields = (opts.fieldNames ?? ['Nom', 'Description']).map((name) => ({ name, type: 'TEXT' }));
const res = await request.post('/api/templates', {
data: {
loreId: opts.loreId,
name: templateName,
description: 'Seeded by Playwright',
defaultNodeId: opts.defaultNodeId,
fields,
},
});
expect(res.ok(), `POST /api/templates -> ${res.status()}`).toBeTruthy();
const tpl = await res.json();
return { id: tpl.id, name: templateName };
}
export async function deleteCampaign(request: APIRequestContext, campaignId: string): Promise<void> {
await request.delete(`/api/campaigns/${campaignId}`).catch(() => undefined);
}
export interface SeededCampaign {
id: string;
name: string;
}
export async function seedCampaign(
request: APIRequestContext,
opts: { name?: string; loreId?: string | null; playerCount?: number } = {},
): Promise<SeededCampaign> {
const name = opts.name ?? `E2E Campaign ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
const res = await request.post('/api/campaigns', {
data: {
name,
description: 'Seeded by Playwright',
playerCount: opts.playerCount ?? 4,
loreId: opts.loreId ?? null,
gameSystemId: null,
},
});
expect(res.ok(), `POST /api/campaigns -> ${res.status()}`).toBeTruthy();
const c = await res.json();
return { id: c.id, name };
}
export interface SeededArc {
id: string;
name: string;
}
export async function seedArc(
request: APIRequestContext,
opts: { campaignId: string; name?: string; order?: number },
): Promise<SeededArc> {
const name = opts.name ?? `E2E Arc ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
const res = await request.post('/api/arcs', {
data: {
campaignId: opts.campaignId,
name,
description: '',
order: opts.order ?? 1,
},
});
expect(res.ok(), `POST /api/arcs -> ${res.status()}`).toBeTruthy();
const a = await res.json();
return { id: a.id, name };
}
export interface SeededChapter {
id: string;
name: string;
}
export async function seedChapter(
request: APIRequestContext,
opts: { arcId: string; name?: string; order?: number },
): Promise<SeededChapter> {
const name = opts.name ?? `E2E Chapter ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
const res = await request.post('/api/chapters', {
data: { arcId: opts.arcId, name, description: '', order: opts.order ?? 1 },
});
expect(res.ok(), `POST /api/chapters -> ${res.status()}`).toBeTruthy();
const c = await res.json();
return { id: c.id, name };
}
export async function getChapterById(
request: APIRequestContext,
chapterId: string,
): Promise<{
id: string;
name: string;
description?: string;
gmNotes?: string | null;
playerObjectives?: string | null;
narrativeStakes?: string | null;
}> {
const res = await request.get(`/api/chapters/${chapterId}`);
expect(res.ok(), `GET /api/chapters/${chapterId} -> ${res.status()}`).toBeTruthy();
return res.json();
}
export interface SeededScene {
id: string;
name: string;
}
export async function seedScene(
request: APIRequestContext,
opts: { chapterId: string; name?: string; order?: number },
): Promise<SeededScene> {
const name = opts.name ?? `E2E Scene ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
const res = await request.post('/api/scenes', {
data: { chapterId: opts.chapterId, name, description: '', order: opts.order ?? 1 },
});
expect(res.ok(), `POST /api/scenes -> ${res.status()}`).toBeTruthy();
const s = await res.json();
return { id: s.id, name };
}
export async function getSceneById(
request: APIRequestContext,
sceneId: string,
): Promise<{
id: string;
name: string;
description?: string;
location?: string | null;
timing?: string | null;
atmosphere?: string | null;
playerNarration?: string | null;
gmSecretNotes?: string | null;
choicesConsequences?: string | null;
combatDifficulty?: string | null;
enemies?: string | null;
branches?: Array<{ label: string; targetSceneId: string; condition?: string }>;
}> {
const res = await request.get(`/api/scenes/${sceneId}`);
expect(res.ok(), `GET /api/scenes/${sceneId} -> ${res.status()}`).toBeTruthy();
return res.json();
}
export async function getArcById(
request: APIRequestContext,
arcId: string,
): Promise<{
id: string;
name: string;
description?: string;
themes?: string | null;
stakes?: string | null;
gmNotes?: string | null;
rewards?: string | null;
resolution?: string | null;
}> {
const res = await request.get(`/api/arcs/${arcId}`);
expect(res.ok(), `GET /api/arcs/${arcId} -> ${res.status()}`).toBeTruthy();
return res.json();
}
export async function getCampaigns(
request: APIRequestContext,
): Promise<Array<{ id: string; name: string; loreId: string | null }>> {
const res = await request.get('/api/campaigns');
expect(res.ok(), `GET /api/campaigns -> ${res.status()}`).toBeTruthy();
return res.json();
}
export async function getPagesForLore(
request: APIRequestContext,
loreId: string,
): Promise<Array<{ id: string; title: string; nodeId: string; templateId: string }>> {
const res = await request.get(`/api/pages?loreId=${loreId}`);
expect(res.ok(), `GET /api/pages -> ${res.status()}`).toBeTruthy();
return res.json();
}
export interface SeededPage {
id: string;
title: string;
}
export async function seedPage(
request: APIRequestContext,
opts: { loreId: string; nodeId: string; templateId: string; title?: string },
): Promise<SeededPage> {
const title = opts.title ?? `E2E Page ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
const res = await request.post('/api/pages', {
data: { loreId: opts.loreId, nodeId: opts.nodeId, templateId: opts.templateId, title },
});
expect(res.ok(), `POST /api/pages -> ${res.status()}`).toBeTruthy();
const page = await res.json();
return { id: page.id, title };
}
export async function getPageById(
request: APIRequestContext,
pageId: string,
): Promise<{
id: string;
title: string;
nodeId: string;
values?: Record<string, string>;
tags?: string[];
notes?: string;
}> {
const res = await request.get(`/api/pages/${pageId}`);
expect(res.ok(), `GET /api/pages/${pageId} -> ${res.status()}`).toBeTruthy();
return res.json();
}
export async function getTemplateById(
request: APIRequestContext,
templateId: string,
): Promise<{
id: string;
name: string;
description?: string;
defaultNodeId?: string | null;
fields: Array<{ name: string; type: string }>;
}> {
const res = await request.get(`/api/templates/${templateId}`);
expect(res.ok(), `GET /api/templates/${templateId} -> ${res.status()}`).toBeTruthy();
return res.json();
}

View File

@@ -0,0 +1,50 @@
import { test, expect } from '@playwright/test';
import {
seedCampaign,
deleteCampaign,
getArcById,
type SeededCampaign,
} from '../../fixtures/api';
test.describe('Arc creation', () => {
let campaign: SeededCampaign;
test.beforeEach(async ({ request }) => {
campaign = await seedCampaign(request);
});
test.afterEach(async ({ request }) => {
if (campaign?.id) await deleteCampaign(request, campaign.id);
});
test('creates an arc and redirects to its view', async ({ page, request }) => {
const arcName = `Arc ${Date.now()}`;
const description = 'Synopsis test';
await page.goto(`/campaigns/${campaign.id}/arcs/create`);
await expect(page.getByRole('heading', { name: /Créer un nouvel arc/i })).toBeVisible();
await page.getByLabel(/Nom de l'arc/i).fill(arcName);
await page.getByLabel(/Description/i).fill(description);
await page.getByRole('button', { name: /^Créer l'arc$/i }).click();
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/arcs/\\d+$`));
const createdId = page.url().match(/\/arcs\/(\d+)$/)?.[1];
expect(createdId).toBeTruthy();
const persisted = await getArcById(request, createdId!);
expect(persisted.name).toBe(arcName);
expect(persisted.description).toBe(description);
});
test('submit is disabled when name is empty', async ({ page }) => {
await page.goto(`/campaigns/${campaign.id}/arcs/create`);
const submit = page.getByRole('button', { name: /^Créer l'arc$/i });
await expect(submit).toBeDisabled();
await page.getByLabel(/Nom de l'arc/i).fill('Quelque chose');
await expect(submit).toBeEnabled();
});
});

View File

@@ -0,0 +1,49 @@
import { test, expect } from '@playwright/test';
import {
seedCampaign,
seedArc,
deleteCampaign,
type SeededCampaign,
type SeededArc,
} from '../../fixtures/api';
test.describe('Arc delete', () => {
let campaign: SeededCampaign;
let arc: SeededArc;
test.beforeEach(async ({ request }) => {
campaign = await seedCampaign(request);
arc = await seedArc(request, { campaignId: campaign.id });
});
test.afterEach(async ({ request }) => {
if (campaign?.id) await deleteCampaign(request, campaign.id);
});
test('deletes an arc after accepting confirm and redirects to the campaign', async ({
page,
request,
}) => {
page.on('dialog', (dialog) => dialog.accept());
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}$`));
const res = await request.get(`/api/arcs/${arc.id}`);
expect(res.status()).toBe(404);
});
test('keeps the arc when confirm is dismissed', async ({ page, request }) => {
page.on('dialog', (dialog) => dialog.dismiss());
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
await page.getByRole('button', { name: /^Supprimer$/i }).click();
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/arcs/${arc.id}/edit$`));
const res = await request.get(`/api/arcs/${arc.id}`);
expect(res.ok()).toBeTruthy();
});
});

View File

@@ -0,0 +1,76 @@
import { test, expect } from '@playwright/test';
import {
seedCampaign,
seedArc,
deleteCampaign,
getArcById,
type SeededCampaign,
type SeededArc,
} from '../../fixtures/api';
test.describe('Arc edit', () => {
let campaign: SeededCampaign;
let arc: SeededArc;
test.beforeEach(async ({ request }) => {
campaign = await seedCampaign(request);
arc = await seedArc(request, { campaignId: campaign.id });
});
test.afterEach(async ({ request }) => {
if (campaign?.id) await deleteCampaign(request, campaign.id);
});
test('form is prefilled with the arc name', async ({ page }) => {
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
await expect(page.getByLabel(/Titre de l'arc/i)).toHaveValue(arc.name);
});
test('edits all narrative fields and persists them to API', async ({ page, request }) => {
const newName = `${arc.name} renamed`;
const values = {
description: "Un arc sombre où la trahison s'installe.",
themes: 'Trahison, rédemption, dette de sang.',
stakes: 'La survie du royaume est en jeu.',
gmNotes: 'Révéler le traître en scène 3.',
rewards: 'Relique ancienne + alliance avec le clan nordique.',
resolution: 'Le héros pardonne au traître ou le tue.',
};
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
await page.getByLabel(/Titre de l'arc/i).fill(newName);
await page.getByLabel(/Synopsis de l'arc/i).fill(values.description);
await page.getByLabel(/Thèmes principaux/i).fill(values.themes);
await page.getByLabel(/Enjeux globaux/i).fill(values.stakes);
await page.getByLabel(/Notes et planification du MJ/i).fill(values.gmNotes);
await page.getByLabel(/Récompenses et progression/i).fill(values.rewards);
await page.getByLabel(/Dénouement prévu/i).fill(values.resolution);
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/arcs/${arc.id}$`));
const persisted = await getArcById(request, arc.id);
expect(persisted.name).toBe(newName);
expect(persisted.description).toBe(values.description);
expect(persisted.themes).toBe(values.themes);
expect(persisted.stakes).toBe(values.stakes);
expect(persisted.gmNotes).toBe(values.gmNotes);
expect(persisted.rewards).toBe(values.rewards);
expect(persisted.resolution).toBe(values.resolution);
});
test('save button is disabled when name is empty', async ({ page }) => {
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
const nameField = page.getByLabel(/Titre de l'arc/i);
const saveBtn = page.getByRole('button', { name: /^Sauvegarder$/i });
await expect(saveBtn).toBeEnabled();
await nameField.fill('');
await expect(saveBtn).toBeDisabled();
await nameField.fill('Valid');
await expect(saveBtn).toBeEnabled();
});
});

View File

@@ -0,0 +1,96 @@
import { test, expect } from '@playwright/test';
import {
seedLoreWithFolder,
deleteLore,
deleteCampaign,
getCampaigns,
type SeededLore,
} from '../../fixtures/api';
test.describe('Campaign creation', () => {
const createdCampaignIds: string[] = [];
let linkedLore: SeededLore;
test.beforeEach(async ({ request }) => {
linkedLore = await seedLoreWithFolder(request);
});
test.afterEach(async ({ request }) => {
while (createdCampaignIds.length) {
await deleteCampaign(request, createdCampaignIds.pop()!);
}
if (linkedLore?.id) await deleteLore(request, linkedLore.id);
});
test('creates a standalone campaign (no lore, no system) and shows it in the grid', async ({
page,
request,
}) => {
const campaignName = `Campagne E2E ${Date.now()}`;
const description = 'Une campagne créée par les tests automatisés.';
await page.goto('/campaigns');
await expect(page.getByRole('heading', { name: /Vos Campagnes|Campagnes/i })).toBeVisible();
await page.locator('.campaign-card.card-new').click();
const modal = page.locator('.modal');
await expect(modal).toBeVisible();
await modal.getByLabel(/Nom de la campagne/i).fill(campaignName);
await modal.getByLabel(/Description/i).fill(description);
await modal.getByLabel(/Nombre de joueurs/i).fill('5');
await modal.getByRole('button', { name: /^Créer la campagne$/i }).click();
await expect(modal).not.toBeVisible();
const newCard = page.locator('.campaign-card', { hasText: campaignName });
await expect(newCard).toBeVisible();
const campaigns = await getCampaigns(request);
const created = campaigns.find((c) => c.name === campaignName);
expect(created).toBeDefined();
expect(created!.loreId).toBeNull();
createdCampaignIds.push(created!.id);
});
test('creates a campaign linked to an existing lore', async ({ page, request }) => {
const campaignName = `Campagne liée ${Date.now()}`;
await page.goto('/campaigns');
await page.locator('.campaign-card.card-new').click();
const modal = page.locator('.modal');
await modal.getByLabel(/Nom de la campagne/i).fill(campaignName);
await modal.getByLabel(/Univers associé/i).selectOption({ label: linkedLore.name });
await modal.getByRole('button', { name: /^Créer la campagne$/i }).click();
await expect(modal).not.toBeVisible();
const campaigns = await getCampaigns(request);
const created = campaigns.find((c) => c.name === campaignName);
expect(created).toBeDefined();
expect(created!.loreId).toBe(linkedLore.id);
createdCampaignIds.push(created!.id);
});
test('submit is disabled without a name and when player count is invalid', async ({ page }) => {
await page.goto('/campaigns');
await page.locator('.campaign-card.card-new').click();
const modal = page.locator('.modal');
const submit = modal.getByRole('button', { name: /^Créer la campagne$/i });
await expect(submit).toBeDisabled();
await modal.getByLabel(/Nom de la campagne/i).fill('Valid name');
await expect(submit).toBeEnabled();
await modal.getByLabel(/Nombre de joueurs/i).fill('0');
await expect(submit).toBeDisabled();
await modal.getByLabel(/Nombre de joueurs/i).fill('3');
await expect(submit).toBeEnabled();
});
});

View File

@@ -0,0 +1,41 @@
import { test, expect } from '@playwright/test';
import { seedCampaign, deleteCampaign, type SeededCampaign } from '../../fixtures/api';
test.describe('Campaign delete', () => {
let campaign: SeededCampaign;
test.beforeEach(async ({ request }) => {
campaign = await seedCampaign(request);
});
test.afterEach(async ({ request }) => {
if (campaign?.id) await deleteCampaign(request, campaign.id);
});
test('deletes a campaign after accepting confirm and redirects to the list', async ({
page,
request,
}) => {
page.on('dialog', (dialog) => dialog.accept());
await page.goto(`/campaigns/${campaign.id}`);
await page.getByRole('button', { name: /^Supprimer$/i }).click();
await expect(page).toHaveURL(/\/campaigns$/);
const res = await request.get(`/api/campaigns/${campaign.id}`);
expect(res.status()).toBe(404);
});
test('keeps the campaign when confirm is dismissed', async ({ page, request }) => {
page.on('dialog', (dialog) => dialog.dismiss());
await page.goto(`/campaigns/${campaign.id}`);
await page.getByRole('button', { name: /^Supprimer$/i }).click();
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
const res = await request.get(`/api/campaigns/${campaign.id}`);
expect(res.ok()).toBeTruthy();
});
});

View File

@@ -0,0 +1,54 @@
import { test, expect } from '@playwright/test';
import {
seedCampaign,
seedArc,
deleteCampaign,
getChapterById,
type SeededCampaign,
type SeededArc,
} from '../../fixtures/api';
test.describe('Chapter creation', () => {
let campaign: SeededCampaign;
let arc: SeededArc;
test.beforeEach(async ({ request }) => {
campaign = await seedCampaign(request);
arc = await seedArc(request, { campaignId: campaign.id });
});
test.afterEach(async ({ request }) => {
if (campaign?.id) await deleteCampaign(request, campaign.id);
});
test('creates a chapter and redirects to its view', async ({ page, request }) => {
const chapterName = `Chapitre ${Date.now()}`;
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/create`);
await expect(page.getByRole('heading', { name: /Créer un nouveau chapitre/i })).toBeVisible();
await page.getByLabel(/Nom du chapitre/i).fill(chapterName);
await page.getByLabel(/Description/i).fill('Synopsis du chapitre');
await page.getByRole('button', { name: /^Créer le chapitre$/i }).click();
await expect(page).toHaveURL(
new RegExp(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/\\d+$`),
);
const createdId = page.url().match(/\/chapters\/(\d+)$/)?.[1];
expect(createdId).toBeTruthy();
const persisted = await getChapterById(request, createdId!);
expect(persisted.name).toBe(chapterName);
});
test('submit is disabled when name is empty', async ({ page }) => {
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/create`);
const submit = page.getByRole('button', { name: /^Créer le chapitre$/i });
await expect(submit).toBeDisabled();
await page.getByLabel(/Nom du chapitre/i).fill('OK');
await expect(submit).toBeEnabled();
});
});

View File

@@ -0,0 +1,72 @@
import { test, expect } from '@playwright/test';
import {
seedCampaign,
seedArc,
seedChapter,
deleteCampaign,
getChapterById,
type SeededCampaign,
type SeededArc,
type SeededChapter,
} from '../../fixtures/api';
test.describe('Chapter edit', () => {
let campaign: SeededCampaign;
let arc: SeededArc;
let chapter: SeededChapter;
test.beforeEach(async ({ request }) => {
campaign = await seedCampaign(request);
arc = await seedArc(request, { campaignId: campaign.id });
chapter = await seedChapter(request, { arcId: arc.id });
});
test.afterEach(async ({ request }) => {
if (campaign?.id) await deleteCampaign(request, campaign.id);
});
test('edits all chapter fields and persists them to API', async ({ page, request }) => {
const newName = `${chapter.name} renamed`;
const values = {
description: 'Le chapitre ouvre sur un village en proie à la peur.',
gmNotes: 'Le maire cache un pacte avec les gobelins.',
playerObjectives: "Découvrir la source des disparitions.",
narrativeStakes: "La confiance du village est en jeu.",
};
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/edit`);
await expect(page.getByLabel(/Titre du chapitre/i)).toHaveValue(chapter.name);
await page.getByLabel(/Titre du chapitre/i).fill(newName);
await page.getByLabel(/Synopsis du chapitre/i).fill(values.description);
await page.getByLabel(/Notes du Maître de Jeu/i).fill(values.gmNotes);
await page.getByLabel(/Objectifs des joueurs/i).fill(values.playerObjectives);
await page.getByLabel(/Enjeux narratifs/i).fill(values.narrativeStakes);
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
await expect(page).toHaveURL(
new RegExp(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}$`),
);
const persisted = await getChapterById(request, chapter.id);
expect(persisted.name).toBe(newName);
expect(persisted.description).toBe(values.description);
expect(persisted.gmNotes).toBe(values.gmNotes);
expect(persisted.playerObjectives).toBe(values.playerObjectives);
expect(persisted.narrativeStakes).toBe(values.narrativeStakes);
});
test('save button is disabled when name is empty', async ({ page }) => {
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/edit`);
const nameField = page.getByLabel(/Titre du chapitre/i);
const saveBtn = page.getByRole('button', { name: /^Sauvegarder$/i });
await expect(saveBtn).toBeEnabled();
await nameField.fill('');
await expect(saveBtn).toBeDisabled();
await nameField.fill('OK');
await expect(saveBtn).toBeEnabled();
});
});

View File

@@ -0,0 +1,64 @@
import { test, expect } from '@playwright/test';
import {
seedCampaign,
seedArc,
seedChapter,
deleteCampaign,
getSceneById,
type SeededCampaign,
type SeededArc,
type SeededChapter,
} from '../../fixtures/api';
test.describe('Scene creation', () => {
let campaign: SeededCampaign;
let arc: SeededArc;
let chapter: SeededChapter;
test.beforeEach(async ({ request }) => {
campaign = await seedCampaign(request);
arc = await seedArc(request, { campaignId: campaign.id });
chapter = await seedChapter(request, { arcId: arc.id });
});
test.afterEach(async ({ request }) => {
if (campaign?.id) await deleteCampaign(request, campaign.id);
});
test('creates a scene and redirects to its view', async ({ page, request }) => {
const sceneName = `Scène ${Date.now()}`;
await page.goto(
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/create`,
);
await expect(page.getByRole('heading', { name: /Créer une nouvelle scène/i })).toBeVisible();
await page.getByLabel(/Nom de la scène/i).fill(sceneName);
await page.getByLabel(/Description/i).fill('Résumé rapide de la scène.');
await page.getByRole('button', { name: /^Créer la scène$/i }).click();
await expect(page).toHaveURL(
new RegExp(
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/\\d+$`,
),
);
const createdId = page.url().match(/\/scenes\/(\d+)$/)?.[1];
expect(createdId).toBeTruthy();
const persisted = await getSceneById(request, createdId!);
expect(persisted.name).toBe(sceneName);
});
test('submit is disabled when name is empty', async ({ page }) => {
await page.goto(
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/create`,
);
const submit = page.getByRole('button', { name: /^Créer la scène$/i });
await expect(submit).toBeDisabled();
await page.getByLabel(/Nom de la scène/i).fill('OK');
await expect(submit).toBeEnabled();
});
});

View File

@@ -0,0 +1,147 @@
import { test, expect } from '@playwright/test';
import {
seedCampaign,
seedArc,
seedChapter,
seedScene,
deleteCampaign,
getSceneById,
type SeededCampaign,
type SeededArc,
type SeededChapter,
type SeededScene,
} from '../../fixtures/api';
test.describe('Scene edit', () => {
let campaign: SeededCampaign;
let arc: SeededArc;
let chapter: SeededChapter;
let scene: SeededScene;
test.beforeEach(async ({ request }) => {
campaign = await seedCampaign(request);
arc = await seedArc(request, { campaignId: campaign.id });
chapter = await seedChapter(request, { arcId: arc.id });
scene = await seedScene(request, { chapterId: chapter.id });
});
test.afterEach(async ({ request }) => {
if (campaign?.id) await deleteCampaign(request, campaign.id);
});
test('edits all text fields across sections and persists them to API', async ({ page, request }) => {
const newName = `${scene.name} renamed`;
const values = {
description: "Les PJ arrivent au village à la nuit tombée.",
location: "Taverne du Dragon d'Or",
timing: 'Soir, pleine lune',
atmosphere: 'Silence pesant, regards fuyants des villageois.',
playerNarration: 'Vous poussez la porte de la taverne…',
gmSecretNotes: 'Le tavernier est complice des bandits.',
choicesConsequences: 'Accepter = piégés à l\'étage. Refuser = filature.',
combatDifficulty: 'Moyenne, 4 bandits niveau 3',
enemies: 'Bandit chef (feuille jointe) + 3 sbires.',
};
await page.goto(
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/${scene.id}/edit`,
);
await expect(page.getByLabel(/Titre de la scène/i)).toHaveValue(scene.name);
await page.getByLabel(/Titre de la scène/i).fill(newName);
await page.getByLabel(/Description courte/i).fill(values.description);
await page.getByLabel(/^Lieu$/i).fill(values.location);
await page.getByLabel(/^Moment$/i).fill(values.timing);
await page.getByLabel(/Ambiance et atmosphère/i).fill(values.atmosphere);
// Les sections suivantes sont fermées par défaut : on les ouvre avant de taper.
// Un clic sur le header de la section toggle son état.
await page.locator('app-expandable-section', { hasText: 'Narration pour les joueurs' }).click();
await page.getByPlaceholder(/Le texte que vous lirez aux joueurs/i).fill(values.playerNarration);
await page.locator('app-expandable-section', { hasText: 'Notes et secrets du MJ' }).click();
await page
.getByPlaceholder(/Informations cachées, indices, éléments secrets/i)
.fill(values.gmSecretNotes);
await page.locator('app-expandable-section', { hasText: 'Choix et conséquences' }).click();
await page
.getByPlaceholder(/Décrivez les différentes options/i)
.fill(values.choicesConsequences);
await page.locator('app-expandable-section', { hasText: 'Combat ou rencontre' }).click();
await page.getByLabel(/Difficulté estimée/i).fill(values.combatDifficulty);
await page.getByLabel(/Ennemis et créatures/i).fill(values.enemies);
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
await expect(page).toHaveURL(
new RegExp(
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/${scene.id}$`,
),
);
const persisted = await getSceneById(request, scene.id);
expect(persisted.name).toBe(newName);
expect(persisted.description).toBe(values.description);
expect(persisted.location).toBe(values.location);
expect(persisted.timing).toBe(values.timing);
expect(persisted.atmosphere).toBe(values.atmosphere);
expect(persisted.playerNarration).toBe(values.playerNarration);
expect(persisted.gmSecretNotes).toBe(values.gmSecretNotes);
expect(persisted.choicesConsequences).toBe(values.choicesConsequences);
expect(persisted.combatDifficulty).toBe(values.combatDifficulty);
expect(persisted.enemies).toBe(values.enemies);
});
test('adds a narrative branch to a sibling scene and persists it', async ({ page, request }) => {
const sibling = await seedScene(request, {
chapterId: chapter.id,
name: 'Scène alternative',
order: 2,
});
await page.goto(
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/${scene.id}/edit`,
);
const branchesSection = page.locator('app-expandable-section', { hasText: 'Branches narratives' });
await branchesSection.click();
await branchesSection.getByRole('button', { name: /Ajouter une branche/i }).click();
const branchItem = branchesSection.locator('.branch-item').first();
await branchItem.getByPlaceholder(/Ex: Si les joueurs attaquent/i).fill('Si les PJ fuient');
await branchItem.locator('select').selectOption({ label: sibling.name });
await branchItem.getByPlaceholder(/Jet de Persuasion/i).fill('Sur échec initiative');
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
await expect(page).toHaveURL(
new RegExp(
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/${scene.id}$`,
),
);
const persisted = await getSceneById(request, scene.id);
expect(persisted.branches).toHaveLength(1);
expect(persisted.branches![0].label).toBe('Si les PJ fuient');
expect(persisted.branches![0].targetSceneId).toBe(sibling.id);
expect(persisted.branches![0].condition).toBe('Sur échec initiative');
});
test('save button is disabled when name is empty', async ({ page }) => {
await page.goto(
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/${scene.id}/edit`,
);
const nameField = page.getByLabel(/Titre de la scène/i);
const saveBtn = page.getByRole('button', { name: /^Sauvegarder$/i });
await expect(saveBtn).toBeEnabled();
await nameField.fill('');
await expect(saveBtn).toBeDisabled();
await nameField.fill('OK');
await expect(saveBtn).toBeEnabled();
});
});

View File

@@ -0,0 +1,77 @@
import { test, expect } from '@playwright/test';
import { deleteLore } from '../../fixtures/api';
test.describe('Lore creation', () => {
const createdLoreIds: string[] = [];
test.afterEach(async ({ request }) => {
while (createdLoreIds.length) {
const id = createdLoreIds.pop()!;
await deleteLore(request, id);
}
});
test('opens the modal, creates a lore, and shows it in the grid', async ({ page, request }) => {
const loreName = `Univers E2E ${Date.now()}`;
const description = "Un univers créé par les tests automatisés.";
await page.goto('/lore');
await expect(page.getByRole('heading', { name: /Vos Univers/i })).toBeVisible();
await page.locator('.lore-card.card-new').click();
const modal = page.locator('.modal');
await expect(modal).toBeVisible();
await expect(modal.getByRole('heading', { name: /Créer un nouveau Lore/i })).toBeVisible();
await modal.getByLabel(/Nom de l'univers/i).fill(loreName);
await modal.getByLabel('Description').fill(description);
await modal.getByRole('button', { name: /^Créer le lore$/i }).click();
await expect(modal).not.toBeVisible();
const newCard = page.locator('.lore-card', { hasText: loreName });
await expect(newCard).toBeVisible();
await expect(newCard).toContainText(description);
const allLores = await request.get('/api/lores').then((r) => r.json());
const created = allLores.find((l: { name: string; id: string }) => l.name === loreName);
expect(created).toBeDefined();
createdLoreIds.push(created.id);
});
test('submit button is disabled when name is empty', async ({ page }) => {
await page.goto('/lore');
await page.locator('.lore-card.card-new').click();
const modal = page.locator('.modal');
const submit = modal.getByRole('button', { name: /^Créer le lore$/i });
await expect(submit).toBeDisabled();
await modal.getByLabel(/Nom de l'univers/i).fill('Quelque chose');
await expect(submit).toBeEnabled();
await modal.getByLabel(/Nom de l'univers/i).fill('');
await expect(submit).toBeDisabled();
});
test('clicking the backdrop closes the modal without creating', async ({ page }) => {
const typedButAbandoned = `Univers abandonné ${Date.now()}`;
await page.goto('/lore');
await page.locator('.lore-card.card-new').click();
const modal = page.locator('.modal');
await expect(modal).toBeVisible();
await modal.getByLabel(/Nom de l'univers/i).fill(typedButAbandoned);
await page.locator('.modal-backdrop').click({ position: { x: 5, y: 5 } });
await expect(modal).not.toBeVisible();
await expect(page.locator('.lore-card', { hasText: typedButAbandoned })).toHaveCount(0);
});
});

View File

@@ -0,0 +1,53 @@
import { test, expect } from '@playwright/test';
import { seedLoreWithFolder, deleteLore, type SeededLore } from '../../fixtures/api';
test.describe('Lore delete', () => {
let seeded: SeededLore;
test.beforeEach(async ({ request }) => {
seeded = await seedLoreWithFolder(request);
});
test.afterEach(async ({ request }) => {
// Best-effort cleanup — ne fait rien si déjà supprimé par le test.
if (seeded?.id) await deleteLore(request, seeded.id);
});
test('deletes a lore after accepting the confirm and redirects to the list', async ({
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 }).click();
// 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 }) => {
page.on('dialog', async (dialog) => {
await dialog.dismiss();
});
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);
const res = await request.get(`/api/lores/${seeded.id}`);
expect(res.ok()).toBeTruthy();
});
});

View File

@@ -0,0 +1,70 @@
import { test, expect } from '@playwright/test';
import { seedLoreWithFolder, deleteLore, getLoreById, type SeededLore } from '../../fixtures/api';
test.describe('Lore inline edit (on detail page)', () => {
let seeded: SeededLore;
test.beforeEach(async ({ request }) => {
seeded = await seedLoreWithFolder(request);
});
test.afterEach(async ({ request }) => {
if (seeded?.id) await deleteLore(request, seeded.id);
});
test('renames the lore inline and persists the change', async ({ page, request }) => {
const newName = `${seeded.name} renamed`;
const newDescription = 'Nouvelle description éditée via UI';
await page.goto(`/lore/${seeded.id}`);
await expect(page.locator('.detail-header h1')).toHaveText(seeded.name);
await page.getByRole('button', { name: /^Modifier$/i }).click();
const nameInput = page.getByLabel(/^Nom$/);
const descInput = page.getByLabel(/^Description$/);
await expect(nameInput).toHaveValue(seeded.name);
await nameInput.fill(newName);
await descInput.fill(newDescription);
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
// Sortie du mode édition : le header bascule en mode lecture avec la nouvelle valeur.
await expect(page.locator('.detail-header h1')).toHaveText(newName);
await expect(page.locator('.detail-header .description')).toHaveText(newDescription);
const persisted = await getLoreById(request, seeded.id);
expect(persisted.name).toBe(newName);
expect(persisted.description).toBe(newDescription);
});
test('save button is disabled when name is emptied during edit', async ({ page }) => {
await page.goto(`/lore/${seeded.id}`);
await page.getByRole('button', { name: /^Modifier$/i }).click();
const saveBtn = page.getByRole('button', { name: /^Sauvegarder$/i });
await expect(saveBtn).toBeEnabled();
await page.getByLabel(/^Nom$/).fill('');
await expect(saveBtn).toBeDisabled();
await page.getByLabel(/^Nom$/).fill(' ');
await expect(saveBtn).toBeDisabled();
});
test('cancel discards the in-flight edits', async ({ page, request }) => {
await page.goto(`/lore/${seeded.id}`);
await page.getByRole('button', { name: /^Modifier$/i }).click();
await page.getByLabel(/^Nom$/).fill('Name jamais sauvegardé');
await page.getByRole('button', { name: /^Annuler$/i }).click();
// Retour en mode lecture avec le nom d'origine.
await expect(page.locator('.detail-header h1')).toHaveText(seeded.name);
const persisted = await getLoreById(request, seeded.id);
expect(persisted.name).toBe(seeded.name);
});
});

View File

@@ -0,0 +1,133 @@
import { test, expect } from '@playwright/test';
import {
seedLoreWithFolder,
seedTemplate,
deleteLore,
getPagesForLore,
type SeededLore,
type SeededTemplate,
} from '../../fixtures/api';
test.describe('Page creation', () => {
let seeded: SeededLore;
let template: SeededTemplate;
test.beforeEach(async ({ request }) => {
seeded = await seedLoreWithFolder(request);
template = await seedTemplate(request, {
loreId: seeded.id,
defaultNodeId: seeded.rootFolderId,
fieldNames: ['Apparence', 'Motivation'],
});
});
test.afterEach(async ({ request }) => {
if (seeded?.id) await deleteLore(request, seeded.id);
});
test('creates an empty page from a template and redirects to edit', async ({ page, request }) => {
const pageTitle = `Maître Eldrin ${Date.now()}`;
await page.goto(`/lore/${seeded.id}/pages/create`);
await expect(page.getByRole('heading', { name: /Créer une nouvelle Page/i })).toBeVisible();
await page.getByLabel(/Titre de la page/i).fill(pageTitle);
await page.locator('.template-card', { hasText: template.name }).click();
await expect(page.locator('.template-card.selected', { hasText: template.name })).toBeVisible();
await expect(page.locator('#page-node')).toHaveValue(seeded.rootFolderId);
await page.getByRole('button', { name: /^Créer la page$/i }).click();
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/pages/[^/]+/edit$`));
const pages = await getPagesForLore(request, seeded.id);
const created = pages.find((p) => p.title === pageTitle);
expect(created).toBeDefined();
expect(created?.templateId).toBe(template.id);
expect(created?.nodeId).toBe(seeded.rootFolderId);
});
test('submit is disabled until title, template and folder are set', async ({ page, request }) => {
// On seed un 2ᵉ template pour empêcher l'auto-sélection (qui se déclenche
// quand un seul template a un defaultNodeId valide). Avec deux candidats,
// l'utilisateur doit choisir explicitement → on retrouve le comportement
// initial du test : submit disabled tant qu'un template n'est pas cliqué.
const secondFolderRes = await request.post('/api/lore-nodes', {
data: { loreId: seeded.id, name: 'Autre dossier', icon: 'folder', description: '' },
});
const secondFolderId = (await secondFolderRes.json()).id;
await seedTemplate(request, {
loreId: seeded.id,
defaultNodeId: secondFolderId,
name: `Second template ${Date.now()}`,
});
await page.goto(`/lore/${seeded.id}/pages/create`);
const submit = page.getByRole('button', { name: /^Créer la page$/i });
await expect(submit).toBeDisabled();
await page.getByLabel(/Titre de la page/i).fill('Un titre');
await expect(submit).toBeDisabled();
await page.locator('.template-card', { hasText: template.name }).click();
await expect(submit).toBeEnabled();
});
test('entering on a folder-scoped route preselects that folder', async ({ page, request }) => {
const pageTitle = `Page scoped ${Date.now()}`;
// Dossier sans template par défaut → pas d'auto-sélection de template,
// l'utilisateur clique manuellement (ce qu'on veut tester ici).
const secondFolderRes = await request.post('/api/lore-nodes', {
data: { loreId: seeded.id, name: 'Autre dossier', icon: 'folder', description: '' },
});
expect(secondFolderRes.ok()).toBeTruthy();
const secondFolderId = (await secondFolderRes.json()).id;
await page.goto(`/lore/${seeded.id}/nodes/${secondFolderId}/pages/create`);
const nodeSelect = page.locator('#page-node');
await expect(nodeSelect).toHaveValue(secondFolderId);
await expect(nodeSelect).toBeDisabled();
await page.getByLabel(/Titre de la page/i).fill(pageTitle);
await page.locator('.template-card', { hasText: template.name }).click();
await page.getByRole('button', { name: /^Créer la page$/i }).click();
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/pages/[^/]+/edit$`));
const pages = await getPagesForLore(request, seeded.id);
expect(pages.find((p) => p.title === pageTitle)?.nodeId).toBe(secondFolderId);
});
test('auto-selects the template on free route when it is the only candidate', async ({ page }) => {
// Le seed donne EXACTEMENT 1 template avec defaultNodeId valide → la
// logique d'auto-sélection doit s'enclencher au chargement.
await page.goto(`/lore/${seeded.id}/pages/create`);
await expect(page.locator('.template-card.selected', { hasText: template.name })).toBeVisible();
await expect(page.locator('#page-node')).toHaveValue(seeded.rootFolderId);
// Conséquence : juste taper un titre suffit pour activer le submit.
const submit = page.getByRole('button', { name: /^Créer la page$/i });
await expect(submit).toBeDisabled();
await page.getByLabel(/Titre de la page/i).fill('Auto');
await expect(submit).toBeEnabled();
});
test('auto-selects the template on folder-scoped route when its defaultNodeId matches', async ({
page,
}) => {
// Le template seedé pointe sur seeded.rootFolderId — entrer sur la route
// folder-scoped de ce dossier doit auto-sélectionner ce template.
await page.goto(`/lore/${seeded.id}/nodes/${seeded.rootFolderId}/pages/create`);
await expect(page.locator('.template-card.selected', { hasText: template.name })).toBeVisible();
await expect(page.locator('#page-node')).toHaveValue(seeded.rootFolderId);
await expect(page.locator('#page-node')).toBeDisabled();
});
});

View File

@@ -0,0 +1,58 @@
import { test, expect } from '@playwright/test';
import {
seedLoreWithFolder,
seedTemplate,
seedPage,
deleteLore,
type SeededLore,
type SeededTemplate,
type SeededPage,
} from '../../fixtures/api';
test.describe('Page delete', () => {
let seeded: SeededLore;
let template: SeededTemplate;
let pageEntity: SeededPage;
test.beforeEach(async ({ request }) => {
seeded = await seedLoreWithFolder(request);
template = await seedTemplate(request, {
loreId: seeded.id,
defaultNodeId: seeded.rootFolderId,
});
pageEntity = await seedPage(request, {
loreId: seeded.id,
nodeId: seeded.rootFolderId,
templateId: template.id,
});
});
test.afterEach(async ({ request }) => {
if (seeded?.id) await deleteLore(request, seeded.id);
});
test('deletes the page after accepting confirm', async ({ page, request }) => {
page.on('dialog', (dialog) => dialog.accept());
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
await page.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}$`));
const res = await request.get(`/api/pages/${pageEntity.id}`);
expect(res.status()).toBe(404);
});
test('keeps the page when confirm is dismissed', async ({ page, request }) => {
page.on('dialog', (dialog) => dialog.dismiss());
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
await page.getByRole('button', { name: /^Supprimer$/i }).click();
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/pages/${pageEntity.id}/edit$`));
const res = await request.get(`/api/pages/${pageEntity.id}`);
expect(res.ok()).toBeTruthy();
});
});

View File

@@ -0,0 +1,95 @@
import { test, expect } from '@playwright/test';
import {
seedLoreWithFolder,
seedTemplate,
seedPage,
deleteLore,
getPageById,
type SeededLore,
type SeededTemplate,
type SeededPage,
} from '../../fixtures/api';
test.describe('Page edit', () => {
let seeded: SeededLore;
let template: SeededTemplate;
let pageEntity: SeededPage;
test.beforeEach(async ({ request }) => {
seeded = await seedLoreWithFolder(request);
template = await seedTemplate(request, {
loreId: seeded.id,
defaultNodeId: seeded.rootFolderId,
fieldNames: ['Apparence', 'Motivation'],
});
pageEntity = await seedPage(request, {
loreId: seeded.id,
nodeId: seeded.rootFolderId,
templateId: template.id,
});
});
test.afterEach(async ({ request }) => {
if (seeded?.id) await deleteLore(request, seeded.id);
});
test('edits title, dynamic fields, notes and persists to API', async ({ page, request }) => {
const newTitle = `Maître Eldrin ${Date.now()}`;
const apparence = 'Vieil homme au regard perçant.';
const motivation = 'Protéger la cité à tout prix.';
const notes = 'MJ : il connaît le secret du roi.';
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
await expect(page.locator('input[name="title"]')).toHaveValue(pageEntity.title);
await page.locator('input[name="title"]').fill(newTitle);
await page.getByPlaceholder('Valeur pour Apparence...').fill(apparence);
await page.getByPlaceholder('Valeur pour Motivation...').fill(motivation);
await page.locator('textarea[name="notes"]').fill(notes);
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/pages/${pageEntity.id}$`));
const persisted = await getPageById(request, pageEntity.id);
expect(persisted.title).toBe(newTitle);
expect(persisted.values?.['Apparence']).toBe(apparence);
expect(persisted.values?.['Motivation']).toBe(motivation);
expect(persisted.notes).toBe(notes);
});
test('adds a tag via chips input and persists it', async ({ page, request }) => {
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
const chipsInput = page.locator('app-chips-input input');
await chipsInput.fill('dangereux');
await chipsInput.press('Enter');
await chipsInput.fill('ancien');
await chipsInput.press('Enter');
await expect(page.locator('app-chips-input').getByText('dangereux')).toBeVisible();
await expect(page.locator('app-chips-input').getByText('ancien')).toBeVisible();
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/pages/${pageEntity.id}$`));
const persisted = await getPageById(request, pageEntity.id);
expect(persisted.tags).toEqual(expect.arrayContaining(['dangereux', 'ancien']));
});
test('save button is disabled when title is empty', async ({ page }) => {
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
const titleInput = page.locator('input[name="title"]');
const saveBtn = page.getByRole('button', { name: /^Sauvegarder$/i });
await expect(saveBtn).toBeEnabled();
await titleInput.fill('');
await expect(saveBtn).toBeDisabled();
await titleInput.fill(' ');
await expect(saveBtn).toBeDisabled();
await titleInput.fill('OK');
await expect(saveBtn).toBeEnabled();
});
});

View File

@@ -0,0 +1,72 @@
import { test, expect } from '@playwright/test';
import { seedLoreWithFolder, deleteLore, getTemplatesForLore, type SeededLore } from '../../fixtures/api';
test.describe('Template creation', () => {
let seeded: SeededLore;
test.beforeEach(async ({ request }) => {
seeded = await seedLoreWithFolder(request);
});
test.afterEach(async ({ request }) => {
if (seeded?.id) await deleteLore(request, seeded.id);
});
test('creates a template with default fields', async ({ page, request }) => {
const templateName = `Auberge ${Date.now()}`;
await page.goto(`/lore/${seeded.id}/templates/create`);
await expect(page.getByRole('heading', { name: /Créer un nouveau Template/i })).toBeVisible();
await page.getByLabel(/Nom du template/i).fill(templateName);
await page.getByLabel('Description').fill('Template créé via E2E');
await page.getByLabel(/Dossier par défaut/i).selectOption({ label: seeded.rootFolderName });
await page.getByRole('button', { name: /^Créer le template$/i }).click();
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
const templates = await getTemplatesForLore(request, seeded.id);
expect(templates.map((t) => t.name)).toContain(templateName);
});
test('submit is disabled when name is empty', async ({ page }) => {
await page.goto(`/lore/${seeded.id}/templates/create`);
const submit = page.getByRole('button', { name: /^Créer le template$/i });
await expect(submit).toBeDisabled();
await page.getByLabel(/Nom du template/i).fill('Valid name');
await page.getByLabel(/Dossier par défaut/i).selectOption({ label: seeded.rootFolderName });
await expect(submit).toBeEnabled();
});
test('can add and remove a custom field before creating', async ({ page, request }) => {
const templateName = `Artefact ${Date.now()}`;
await page.goto(`/lore/${seeded.id}/templates/create`);
await page.getByLabel(/Nom du template/i).fill(templateName);
await page.getByLabel(/Dossier par défaut/i).selectOption({ label: seeded.rootFolderName });
const addFieldInput = page.getByPlaceholder('+ Ajouter un champ');
await addFieldInput.fill('Pouvoir');
await addFieldInput.press('Enter');
await expect(page.locator('.fields-list .field-chip', { hasText: 'Pouvoir' })).toBeVisible();
await addFieldInput.fill('Origine');
await addFieldInput.press('Enter');
await expect(page.locator('.fields-list .field-chip', { hasText: 'Origine' })).toBeVisible();
const origineRow = page.locator('.fields-list .field-row', { hasText: 'Origine' });
await origineRow.getByRole('button', { name: 'Supprimer' }).click();
await expect(page.locator('.fields-list .field-chip', { hasText: 'Origine' })).toHaveCount(0);
await page.getByRole('button', { name: /^Créer le template$/i }).click();
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
const templates = await getTemplatesForLore(request, seeded.id);
expect(templates.find((t) => t.name === templateName)).toBeDefined();
});
});

View File

@@ -0,0 +1,51 @@
import { test, expect } from '@playwright/test';
import {
seedLoreWithFolder,
seedTemplate,
deleteLore,
getTemplatesForLore,
type SeededLore,
type SeededTemplate,
} from '../../fixtures/api';
test.describe('Template delete', () => {
let seeded: SeededLore;
let template: SeededTemplate;
test.beforeEach(async ({ request }) => {
seeded = await seedLoreWithFolder(request);
template = await seedTemplate(request, {
loreId: seeded.id,
defaultNodeId: seeded.rootFolderId,
});
});
test.afterEach(async ({ request }) => {
if (seeded?.id) await deleteLore(request, seeded.id);
});
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();
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
const templates = await getTemplatesForLore(request, seeded.id);
expect(templates.find((t) => t.id === template.id)).toBeUndefined();
});
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();
// On reste sur l'écran d'édition (l'URL ne change pas).
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/templates/${template.id}$`));
const templates = await getTemplatesForLore(request, seeded.id);
expect(templates.find((t) => t.id === template.id)).toBeDefined();
});
});

View File

@@ -0,0 +1,74 @@
import { test, expect } from '@playwright/test';
import {
seedLoreWithFolder,
seedTemplate,
deleteLore,
getTemplateById,
type SeededLore,
type SeededTemplate,
} from '../../fixtures/api';
test.describe('Template edit', () => {
let seeded: SeededLore;
let template: SeededTemplate;
test.beforeEach(async ({ request }) => {
seeded = await seedLoreWithFolder(request);
template = await seedTemplate(request, {
loreId: seeded.id,
defaultNodeId: seeded.rootFolderId,
fieldNames: ['Nom', 'Description'],
});
});
test.afterEach(async ({ request }) => {
if (seeded?.id) await deleteLore(request, seeded.id);
});
test('form is prefilled with the current template data', async ({ page }) => {
await page.goto(`/lore/${seeded.id}/templates/${template.id}`);
await expect(page.getByLabel(/^Nom$/)).toHaveValue(template.name);
await expect(page.getByLabel(/Dossier par défaut/i)).toHaveValue(seeded.rootFolderId);
await expect(page.locator('.fields-list .field-chip', { hasText: 'Nom' })).toBeVisible();
await expect(page.locator('.fields-list .field-chip', { hasText: 'Description' })).toBeVisible();
});
test('renames the template and persists to API', async ({ page, request }) => {
const newName = `${template.name} renamed`;
await page.goto(`/lore/${seeded.id}/templates/${template.id}`);
const nameInput = page.getByLabel(/^Nom$/);
await nameInput.fill(newName);
await page.getByLabel(/Description/i).fill('Description mise à jour');
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
const persisted = await getTemplateById(request, template.id);
expect(persisted.name).toBe(newName);
expect(persisted.description).toBe('Description mise à jour');
});
test('adds a new field, removes an existing one, and persists the order', async ({ page, request }) => {
await page.goto(`/lore/${seeded.id}/templates/${template.id}`);
const addInput = page.getByPlaceholder('+ Ajouter un champ');
await addInput.fill('Stats');
await addInput.press('Enter');
await expect(page.locator('.fields-list .field-chip', { hasText: 'Stats' })).toBeVisible();
const descriptionRow = page.locator('.fields-list .field-row', { hasText: 'Description' });
await descriptionRow.getByRole('button', { name: 'Supprimer' }).click();
await expect(page.locator('.fields-list .field-chip', { hasText: 'Description' })).toHaveCount(0);
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
const persisted = await getTemplateById(request, template.id);
const names = persisted.fields.map((f) => f.name);
expect(names).toEqual(['Nom', 'Stats']);
});
});

View File

@@ -0,0 +1,15 @@
import { test, expect } from '@playwright/test';
test.describe('Smoke', () => {
test('app loads without uncaught errors', async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', (err) => errors.push(err.message));
await page.goto('/');
await expect(page.locator('app-sidebar')).toBeVisible();
await expect(page.locator('main.main-content')).toBeAttached();
expect(errors, `Page errors:\n${errors.join('\n')}`).toEqual([]);
});
});

68
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "loremind-web",
"version": "0.4.0",
"version": "0.6.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "loremind-web",
"version": "0.4.0",
"version": "0.6.5",
"dependencies": {
"@angular/animations": "^17.0.0",
"@angular/common": "^17.0.0",
@@ -27,6 +27,7 @@
"@angular-devkit/build-angular": "^17.0.0",
"@angular/cli": "^17.0.0",
"@angular/compiler-cli": "^17.0.0",
"@playwright/test": "^1.59.1",
"typescript": "~5.2.2"
}
},
@@ -3137,6 +3138,22 @@
"node": ">=14"
}
},
"node_modules/@playwright/test": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
@@ -9063,6 +9080,53 @@
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.4.35",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz",

View File

@@ -1,13 +1,17 @@
{
"name": "loremind-web",
"version": "0.6.1",
"version": "0.6.5",
"description": "LoreMind Frontend - Angular",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
"test": "ng test",
"e2e": "playwright test",
"e2e:ui": "playwright test --ui",
"e2e:headed": "playwright test --headed",
"e2e:report": "playwright show-report"
},
"private": true,
"dependencies": {
@@ -30,6 +34,7 @@
"@angular-devkit/build-angular": "^17.0.0",
"@angular/cli": "^17.0.0",
"@angular/compiler-cli": "^17.0.0",
"@playwright/test": "^1.59.1",
"typescript": "~5.2.2"
}
}

24
web/playwright.config.ts Normal file
View File

@@ -0,0 +1,24 @@
import { defineConfig, devices } from '@playwright/test';
const baseURL = process.env['E2E_BASE_URL'] || 'http://localhost:8081';
export default defineConfig({
testDir: './e2e/tests',
fullyParallel: true,
forbidOnly: !!process.env['CI'],
retries: process.env['CI'] ? 2 : 0,
workers: process.env['CI'] ? 1 : undefined,
reporter: process.env['CI'] ? [['html', { open: 'never' }], ['list']] : 'html',
use: {
baseURL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

View File

@@ -5,6 +5,10 @@
.main-content {
flex: 1;
padding: 2rem;
// padding-top: 0 — sinon le contenu defile dans la zone de padding
// au-dessus du `.page-header` sticky (top: 0 pin sur l'edge interne du
// padding-box). Chaque page-wrapper definit deja son propre padding-top
// qui devient l'unique source d'espacement haut.
padding: 0 2rem 2rem;
overflow-y: auto;
}

View File

@@ -7,8 +7,9 @@
<form [formGroup]="form" (ngSubmit)="submit()" class="arc-form">
<div class="field">
<label>Nom de l'arc *</label>
<label for="arc-create-name">Nom de l'arc *</label>
<input
id="arc-create-name"
type="text"
formControlName="name"
placeholder="Ex: L'Ombre du Nord"
@@ -17,14 +18,20 @@
</div>
<div class="field">
<label>Description</label>
<label for="arc-create-description">Description</label>
<textarea
id="arc-create-description"
formControlName="description"
placeholder="Décrivez l'arc narratif principal..."
rows="5">
</textarea>
</div>
<div class="field">
<label>Icône</label>
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
Créer l'arc

View File

@@ -9,6 +9,8 @@ import { CharacterService } from '../../services/character.service';
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';
/**
* Écran de création d'un nouvel Arc narratif (contexte Campagne).
@@ -18,15 +20,17 @@ import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper
@Component({
selector: 'app-arc-create',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule],
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, IconPickerComponent],
templateUrl: './arc-create.component.html',
styleUrls: ['./arc-create.component.scss']
})
export class ArcCreateComponent implements OnInit, OnDestroy {
readonly BookOpen = BookOpen;
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
form: FormGroup;
campaignId = '';
selectedIcon: string | null = null;
private existingArcCount = 0;
constructor(
@@ -80,7 +84,8 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
name: this.form.value.name,
description: this.form.value.description,
campaignId: this.campaignId,
order: this.existingArcCount + 1
order: this.existingArcCount + 1,
icon: this.selectedIcon
}).subscribe({
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', created.id]),
error: () => console.error('Erreur lors de la création de l\'arc')

View File

@@ -13,6 +13,14 @@
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
<button type="button" class="btn-danger" (click)="delete()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
<button type="button" class="btn-primary" (click)="submit()" [disabled]="form.invalid">
Sauvegarder
</button>
</div>
</div>
@@ -43,8 +51,9 @@
</div>
<div class="field">
<label>Titre de l'arc *</label>
<label for="arc-edit-name">Titre de l'arc *</label>
<input
id="arc-edit-name"
type="text"
formControlName="name"
placeholder="Ex: L'Ombre du Nord"
@@ -53,26 +62,34 @@
</div>
<div class="field">
<label>Synopsis de l'arc</label>
<label for="arc-edit-description">Synopsis de l'arc</label>
<textarea
id="arc-edit-description"
formControlName="description"
placeholder="Décrivez l'histoire principale de cet arc narratif..."
rows="5">
</textarea>
</div>
<div class="field">
<label>Icône</label>
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
</div>
<div class="field-row">
<div class="field">
<label>Thèmes principaux</label>
<label for="arc-edit-themes">Thèmes principaux</label>
<textarea
id="arc-edit-themes"
formControlName="themes"
placeholder="Quels sont les thèmes explorés dans cet arc ? (trahison, rédemption...)"
rows="4">
</textarea>
</div>
<div class="field">
<label>Enjeux globaux</label>
<label for="arc-edit-stakes">Enjeux globaux</label>
<textarea
id="arc-edit-stakes"
formControlName="stakes"
placeholder="Quels sont les enjeux majeurs de cet arc pour les personnages ?"
rows="4">
@@ -81,8 +98,9 @@
</div>
<div class="field">
<label>Notes et planification du MJ</label>
<label for="arc-edit-gm-notes">Notes et planification du MJ</label>
<textarea
id="arc-edit-gm-notes"
formControlName="gmNotes"
placeholder="Vos notes sur la direction de l'arc, les twists prévus, les révélations importantes..."
rows="5">
@@ -91,8 +109,9 @@
</div>
<div class="field">
<label>Récompenses et progression</label>
<label for="arc-edit-rewards">Récompenses et progression</label>
<textarea
id="arc-edit-rewards"
formControlName="rewards"
placeholder="Quelles récompenses les joueurs obtiendront-ils ? Objets, niveaux, connaissances, contacts..."
rows="4">
@@ -100,8 +119,9 @@
</div>
<div class="field">
<label>Dénouement prévu</label>
<label for="arc-edit-resolution">Dénouement prévu</label>
<textarea
id="arc-edit-resolution"
formControlName="resolution"
placeholder="Comment cet arc devrait-il se terminer ? Quelles sont les issues possibles ?"
rows="4">
@@ -129,17 +149,6 @@
</small>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
Sauvegarder
</button>
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
<button type="button" class="btn-danger" (click)="delete()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</form>
</div>

View File

@@ -16,6 +16,8 @@ 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';
/**
* Écran de détail/modification d'un Arc.
@@ -29,13 +31,15 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
@Component({
selector: 'app-arc-edit',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent],
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent],
templateUrl: './arc-edit.component.html',
styleUrls: ['./arc-edit.component.scss']
})
export class ArcEditComponent implements OnInit, OnDestroy {
readonly Trash2 = Trash2;
readonly Sparkles = Sparkles;
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
selectedIcon: string | null = null;
/** État drawer chat IA (b5.7 — intégration Campagne). */
chatOpen = false;
@@ -122,6 +126,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
this.loreId = loreId;
this.availablePages = pages;
this.relatedPageIds = [...(arc.relatedPageIds ?? [])];
this.selectedIcon = arc.icon ?? null;
this.illustrationImageIds = [...(arc.illustrationImageIds ?? [])];
this.mapImageIds = [...(arc.mapImageIds ?? [])];
this.pageTitleService.set(arc.name);
@@ -167,7 +172,8 @@ export class ArcEditComponent implements OnInit, OnDestroy {
resolution: this.form.value.resolution,
relatedPageIds: this.relatedPageIds,
illustrationImageIds: this.illustrationImageIds,
mapImageIds: this.mapImageIds
mapImageIds: this.mapImageIds,
icon: this.selectedIcon
}).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]),
error: () => console.error('Erreur lors de la sauvegarde')

View File

@@ -2,7 +2,10 @@
<header class="view-header">
<div>
<h1>{{ arc.name }}</h1>
<h1>
<lucide-icon *ngIf="arc.icon" [img]="resolveCampaignIcon(arc.icon)" [size]="22" class="title-icon"></lucide-icon>
{{ arc.name }}
</h1>
<p class="view-subtitle">Arc narratif</p>
</div>
<div class="view-actions">

View File

@@ -4,6 +4,7 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
import { resolveCampaignIcon } from '../campaign-icons';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { PageService } from '../../services/page.service';
@@ -29,6 +30,7 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
export class ArcViewComponent implements OnInit, OnDestroy {
readonly Pencil = Pencil;
readonly Trash2 = Trash2;
readonly resolveCampaignIcon = resolveCampaignIcon;
campaignId = '';
arcId = '';

View File

@@ -11,8 +11,9 @@
<form [formGroup]="form" (ngSubmit)="submit()">
<div class="field">
<label>Nom de la campagne *</label>
<label for="campaign-name">Nom de la campagne *</label>
<input
id="campaign-name"
type="text"
formControlName="name"
placeholder="Ex: L'Ombre du Nord, Les Héritiers Oubliés..."
@@ -21,8 +22,9 @@
</div>
<div class="field">
<label>Description / Pitch</label>
<label for="campaign-description">Description / Pitch</label>
<textarea
id="campaign-description"
formControlName="description"
placeholder="Résumez l'intrigue principale de votre campagne..."
rows="5"
@@ -30,13 +32,13 @@
</div>
<div class="field">
<label>Nombre de joueurs</label>
<input type="number" formControlName="playerCount" min="1" />
<label for="campaign-player-count">Nombre de joueurs</label>
<input id="campaign-player-count" type="number" formControlName="playerCount" min="1" />
</div>
<div class="field">
<label>Univers associé</label>
<select formControlName="loreId">
<label for="campaign-lore">Univers associé</label>
<select id="campaign-lore" formControlName="loreId">
<option value="">— Aucun univers (campagne libre) —</option>
<option *ngFor="let lore of availableLores" [value]="lore.id">{{ lore.name }}</option>
</select>
@@ -47,8 +49,8 @@
</div>
<div class="field">
<label>Système de JDR</label>
<select formControlName="gameSystemId">
<label for="campaign-game-system">Système de JDR</label>
<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>
</select>

View File

@@ -0,0 +1,66 @@
import {
Bookmark,
Theater, Drama, Compass, Flag, Calendar, Map, Castle, Tent,
Swords, Skull, Crown, Heart, Eye, Footprints, Dice5, Hourglass,
Flame, Snowflake, Cloud, Sun, Moon, Star, Zap, Target,
Music, MessageCircle, Lock, Key as KeyIcon, Coins, Gift,
LucideIconData
} from 'lucide-angular';
// Type local equivalent a IconOption de lore-icons. Duplique volontairement
// pour eviter une dependance circulaire (lore-icons importe ce fichier).
export interface CampaignIconOption {
key: string;
icon: LucideIconData;
}
/**
* Banque d'icones dediee aux entites narratives d'une campagne (arc, chapitre, scene).
*
* Pourquoi separe de LORE_ICON_OPTIONS ? Les icones lore sont plutot orientees
* "objets de monde" (chateau, foret, dragon...). Ici on est sur du sequencement
* narratif et de la mise en scene : ambiances, actes, decisions. Cles uniques
* (prefixees `c-` quand un nom existerait deja dans le lore) pour eviter les
* collisions avec LORE_ICON_OPTIONS — le resolver consulte les deux registres.
*/
export const CAMPAIGN_ICON_OPTIONS: CampaignIconOption[] = [
{ key: 'c-theater', icon: Theater },
{ key: 'c-drama', icon: Drama },
{ key: 'c-compass', icon: Compass },
{ key: 'c-flag', icon: Flag },
{ key: 'c-calendar', icon: Calendar },
{ key: 'c-map', icon: Map },
{ key: 'c-castle', icon: Castle },
{ key: 'c-tent', icon: Tent },
{ key: 'c-swords', icon: Swords },
{ key: 'c-skull', icon: Skull },
{ key: 'c-crown', icon: Crown },
{ key: 'c-heart', icon: Heart },
{ key: 'c-eye', icon: Eye },
{ key: 'c-footprints', icon: Footprints },
{ key: 'c-dice', icon: Dice5 },
{ key: 'c-hourglass', icon: Hourglass },
{ key: 'c-flame', icon: Flame },
{ key: 'c-snowflake', icon: Snowflake },
{ key: 'c-cloud', icon: Cloud },
{ key: 'c-sun', icon: Sun },
{ key: 'c-moon', icon: Moon },
{ key: 'c-star', icon: Star },
{ key: 'c-zap', icon: Zap },
{ key: 'c-target', icon: Target },
{ key: 'c-music', icon: Music },
{ key: 'c-message', icon: MessageCircle },
{ key: 'c-lock', icon: Lock },
{ key: 'c-key', icon: KeyIcon },
{ key: 'c-coins', icon: Coins },
{ key: 'c-gift', icon: Gift },
];
/** Icone par defaut quand une entite narrative n'en a pas. */
export const DEFAULT_CAMPAIGN_ICON: LucideIconData = Bookmark;
/** Resolveur dedie. Prefere passer par `resolveIcon` dans lore-icons qui consulte les deux. */
export function resolveCampaignIcon(key: string | null | undefined): LucideIconData {
if (!key) return DEFAULT_CAMPAIGN_ICON;
return CAMPAIGN_ICON_OPTIONS.find(o => o.key === key)?.icon ?? DEFAULT_CAMPAIGN_ICON;
}

View File

@@ -109,11 +109,13 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
const sceneItems: TreeItem[] = sortedScenes.map(sc => ({
id: `scene-${sc.id}`,
label: sc.name,
iconKey: sc.icon ?? undefined,
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}/scenes/${sc.id}`
}));
return {
id: `chapter-${ch.id}`,
label: ch.name,
iconKey: ch.icon ?? undefined,
children: sceneItems,
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}`,
createActions: [{
@@ -127,6 +129,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
return {
id: `arc-${arc.id}`,
label: arc.name,
iconKey: arc.icon ?? undefined,
children: chapterItems,
route: `/campaigns/${campaignId}/arcs/${arc.id}`,
sectionHeaderBefore: idx === 0 ? 'Narration' : undefined,

View File

@@ -8,8 +8,9 @@
<form [formGroup]="form" (ngSubmit)="submit()" class="chapter-form">
<div class="field">
<label>Nom du chapitre *</label>
<label for="chapter-create-name">Nom du chapitre *</label>
<input
id="chapter-create-name"
type="text"
formControlName="name"
placeholder="Ex: Chapitre 1: Les Disparitions"
@@ -18,14 +19,20 @@
</div>
<div class="field">
<label>Description</label>
<label for="chapter-create-description">Description</label>
<textarea
id="chapter-create-description"
formControlName="description"
placeholder="Décrivez ce chapitre..."
rows="5">
</textarea>
</div>
<div class="field">
<label>Icône</label>
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
Créer le chapitre

View File

@@ -9,6 +9,8 @@ import { CharacterService } from '../../services/character.service';
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';
/**
* Écran de création d'un nouveau chapitre rattaché à un arc.
@@ -17,11 +19,14 @@ import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper
@Component({
selector: 'app-chapter-create',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule],
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, IconPickerComponent],
templateUrl: './chapter-create.component.html',
styleUrls: ['./chapter-create.component.scss']
})
export class ChapterCreateComponent implements OnInit, OnDestroy {
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
selectedIcon: string | null = null;
form: FormGroup;
campaignId = '';
arcId = '';
@@ -82,7 +87,8 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
name: this.form.value.name,
description: this.form.value.description,
arcId: this.arcId,
order: this.existingChapterCount + 1
order: this.existingChapterCount + 1,
icon: this.selectedIcon
}).subscribe({
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', created.id]),
error: () => console.error('Erreur lors de la création du chapitre')

View File

@@ -13,6 +13,14 @@
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
<button type="button" class="btn-danger" (click)="delete()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
<button type="button" class="btn-primary" (click)="submit()" [disabled]="form.invalid">
Sauvegarder
</button>
</div>
</div>
@@ -43,8 +51,9 @@
</div>
<div class="field">
<label>Titre du chapitre *</label>
<label for="chapter-edit-name">Titre du chapitre *</label>
<input
id="chapter-edit-name"
type="text"
formControlName="name"
placeholder="Ex: Chapitre 1: Les Disparitions"
@@ -53,8 +62,9 @@
</div>
<div class="field">
<label>Synopsis du chapitre</label>
<label for="chapter-edit-description">Synopsis du chapitre</label>
<textarea
id="chapter-edit-description"
formControlName="description"
placeholder="Décrivez brièvement ce qui se passe dans ce chapitre..."
rows="5">
@@ -62,8 +72,14 @@
</div>
<div class="field">
<label>Notes du Maître de Jeu</label>
<label>Icône</label>
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
</div>
<div class="field">
<label for="chapter-edit-gm-notes">Notes du Maître de Jeu</label>
<textarea
id="chapter-edit-gm-notes"
formControlName="gmNotes"
placeholder="Vos notes privées sur le déroulement du chapitre, les événements clés, les rebondissements..."
rows="6">
@@ -73,16 +89,18 @@
<div class="field-row">
<div class="field">
<label>Objectifs des joueurs</label>
<label for="chapter-edit-player-objectives">Objectifs des joueurs</label>
<textarea
id="chapter-edit-player-objectives"
formControlName="playerObjectives"
placeholder="Que doivent accomplir les joueurs dans ce chapitre ?"
rows="4">
</textarea>
</div>
<div class="field">
<label>Enjeux narratifs</label>
<label for="chapter-edit-narrative-stakes">Enjeux narratifs</label>
<textarea
id="chapter-edit-narrative-stakes"
formControlName="narrativeStakes"
placeholder="Quels sont les enjeux dramatiques ?"
rows="4">
@@ -111,17 +129,6 @@
</small>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
Sauvegarder
</button>
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
<button type="button" class="btn-danger" (click)="delete()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</form>
</div>

View File

@@ -16,6 +16,8 @@ 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';
/**
* Écran de détail/modification d'un Chapitre.
@@ -27,13 +29,15 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
@Component({
selector: 'app-chapter-edit',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent],
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent],
templateUrl: './chapter-edit.component.html',
styleUrls: ['./chapter-edit.component.scss']
})
export class ChapterEditComponent implements OnInit, OnDestroy {
readonly Trash2 = Trash2;
readonly Sparkles = Sparkles;
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
selectedIcon: string | null = null;
/** État drawer chat IA (b5.7 — intégration Campagne). */
chatOpen = false;
@@ -113,6 +117,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
this.loreId = loreId;
this.availablePages = pages;
this.relatedPageIds = [...(chapter.relatedPageIds ?? [])];
this.selectedIcon = chapter.icon ?? null;
this.illustrationImageIds = [...(chapter.illustrationImageIds ?? [])];
this.mapImageIds = [...(chapter.mapImageIds ?? [])];
this.form.patchValue({
@@ -153,7 +158,8 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
narrativeStakes: this.form.value.narrativeStakes,
relatedPageIds: this.relatedPageIds,
illustrationImageIds: this.illustrationImageIds,
mapImageIds: this.mapImageIds
mapImageIds: this.mapImageIds,
icon: this.selectedIcon
}).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]),
error: () => console.error('Erreur lors de la sauvegarde')

View File

@@ -16,11 +16,16 @@
</div>
<div class="graph-container" *ngIf="scenes.length > 0">
<svg [attr.width]="svgWidth" [attr.height]="svgHeight" class="graph-svg">
<svg #svgEl
[attr.width]="svgWidth" [attr.height]="svgHeight"
class="graph-svg"
(pointermove)="onPointerMove($event)"
(pointerup)="onPointerUp($event)"
(pointercancel)="onPointerUp($event)">
<defs>
<marker id="arrowhead" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#6b7280" />
<path d="M 0 0 L 10 5 L 0 10 z" fill="#b8c0cc" />
</marker>
</defs>
@@ -28,20 +33,25 @@
<g class="edge" *ngFor="let edge of edges">
<line [attr.x1]="edge.x1" [attr.y1]="edge.y1"
[attr.x2]="edge.x2" [attr.y2]="edge.y2"
stroke="#6b7280" stroke-width="2"
stroke="#b8c0cc" stroke-width="2"
marker-end="url(#arrowhead)" />
<text *ngIf="edge.label"
[attr.x]="edge.labelX"
[attr.y]="edge.labelY"
text-anchor="middle"
class="edge-label">
class="edge-label"
[class.dragging]="draggingLabelKey === edge.key"
(pointerdown)="onLabelPointerDown($event, edge)">
{{ edge.label }}
</text>
</g>
</g>
<g class="nodes">
<g class="node" *ngFor="let node of nodes" (click)="openScene(node.id)">
<g class="node"
[class.dragging]="draggingId === node.id"
*ngFor="let node of nodes"
(pointerdown)="onPointerDown($event, node)">
<title>{{ node.name }}</title>
<rect [attr.x]="node.x" [attr.y]="node.y"
[attr.width]="NODE_WIDTH" [attr.height]="NODE_HEIGHT"
@@ -57,7 +67,7 @@
</svg>
<small class="graph-hint">
💡 Cliquez sur une scène pour l'ouvrir. Les scènes non reliées au point d'entrée (scène d'ordre 1) apparaissent en bas.
💡 Cliquez sur une scène pour l'ouvrir, ou glissez-la pour réorganiser la carte. Les scènes non reliées au point d'entrée (scène d'ordre 1) apparaissent en bas.
</small>
</div>

View File

@@ -11,7 +11,7 @@
margin-bottom: 2rem;
.subtitle {
color: #6b7280;
color: #9ca3af;
font-size: 0.9rem;
margin: 0.25rem 0 0;
}
@@ -20,15 +20,17 @@
.graph-empty {
padding: 2rem;
text-align: center;
color: #6b7280;
background: #f9fafb;
color: #9ca3af;
background: #14141f;
border-radius: 8px;
border: 1px dashed #d1d5db;
border: 1px dashed #374151;
}
.graph-container {
background: #fafafa;
border: 1px solid #e5e7eb;
// Fond legerement plus sombre que la couleur des noeuds : creuse l'image
// sans aller jusqu'au noir pur (qui « brulerait » par contraste).
background: #0d0d18;
border: 1px solid #374151;
border-radius: 12px;
padding: 20px;
overflow: auto;
@@ -41,14 +43,27 @@
.graph-svg {
display: block;
max-width: 100%;
// Empeche le browser de clipper le contenu qui depasserait le viewport SVG
// pendant un drag — le scroll du conteneur prend le relais.
overflow: visible;
// Évite que le navigateur intercepte le drag pour faire de la sélection texte
// ou du panning natif sur les nœuds.
touch-action: none;
user-select: none;
}
.node {
cursor: pointer;
cursor: grab;
// Ombre portee douce pour detacher chaque noeud du fond. Faible alpha pour
// rester subtil sur fond sombre, large diffusion pour rester organique.
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.5));
.node-box {
fill: #ffffff;
stroke: #1f2937;
// Indigo desature : reprend la palette accent (#6c63ff) en version assombrie
// pour donner du caractere aux noeuds sans saturer la vue. Bordure assortie
// un peu plus claire pour bien dessiner le contour.
fill: #1f1d3a;
stroke: #4f4a7a;
stroke-width: 2;
transition: fill 0.15s ease, stroke 0.15s ease;
}
@@ -56,31 +71,47 @@
.node-label {
font-size: 0.9rem;
font-weight: 500;
fill: #1f2937;
fill: #f3f4f6;
pointer-events: none;
}
&:hover .node-box {
fill: #eef2ff;
stroke: #4f46e5;
fill: #2c2952;
stroke: #8b80ff;
}
&.dragging {
cursor: grabbing;
.node-box {
fill: #2c2952;
stroke: #8b80ff;
filter: drop-shadow(0 4px 10px rgba(108, 99, 255, 0.35));
}
}
}
.edge-label {
font-size: 0.75rem;
fill: #4b5563;
fill: #e5e7eb;
font-style: italic;
// Halo blanc autour du texte pour garantir la lisibilité même s'il passe
// sur une ligne ou un autre élément.
cursor: grab;
// Halo sombre autour du texte pour rester lisible quand un label passe
// par-dessus une arête ou un autre nœud. Aligne sur la couleur du fond.
paint-order: stroke;
stroke: #fafafa;
stroke-width: 3px;
stroke: #0d0d18;
stroke-width: 4px;
stroke-linejoin: round;
&:hover { fill: #ffffff; }
&.dragging {
cursor: grabbing;
fill: #ffffff;
}
}
.graph-hint {
display: block;
margin-top: 1rem;
color: #6b7280;
color: #9ca3af;
font-size: 0.85rem;
}

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Component, OnInit, OnDestroy, ElementRef, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin } from 'rxjs';
@@ -11,7 +11,7 @@ import { Campaign, Chapter, Scene } from '../../services/campaign.model';
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
interface GraphNode { id: string; name: string; displayName: string; x: number; y: number; }
interface GraphEdge { label: string; x1: number; y1: number; x2: number; y2: number; labelX: number; labelY: number; }
interface GraphEdge { key: string; label: string; x1: number; y1: number; x2: number; y2: number; labelX: number; labelY: number; }
/**
* Vue graphique d'un chapitre : organigramme des scènes et branches narratives.
@@ -45,6 +45,24 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
svgWidth = 600;
svgHeight = 400;
@ViewChild('svgEl') svgEl?: ElementRef<SVGSVGElement>;
// Etat de drag : id du noeud manipule, offset entre le pointeur et le coin
// haut-gauche du noeud (en coords SVG), et flag indiquant qu'un mouvement
// significatif a eu lieu (pour distinguer clic vs glisser).
draggingId: string | null = null;
draggingLabelKey: string | null = null;
private dragOffsetX = 0;
private dragOffsetY = 0;
private dragMoved = false;
private readonly DRAG_THRESHOLD = 4;
// Decalage manuel applique a chaque label d'arete, indexe par cle stable
// (sourceId|targetId|branchIdx). Persiste a travers les recalculs d'aretes
// pour que le label suive son arete quand on deplace un noeud, tout en
// conservant le repositionnement manuel de l'utilisateur.
private labelOffsets = new Map<string, { dx: number; dy: number }>();
constructor(
private route: ActivatedRoute,
private router: Router,
@@ -153,7 +171,18 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
});
}
const nodeMap = new Map(nodes.map(n => [n.id, n]));
this.nodes = nodes;
this.recomputeEdges();
this.svgWidth = Math.max(rowWidth + 40, 600);
this.svgHeight = (orphanLevel + 1) * (this.NODE_HEIGHT + this.V_SPACING) + 40;
}
/**
* Recalcule la geometrie des aretes a partir des positions courantes des noeuds.
* Appele apres le layout initial et apres chaque deplacement manuel d'un noeud.
*/
private recomputeEdges(): void {
const nodeMap = new Map(this.nodes.map(n => [n.id, n]));
const edges: GraphEdge[] = [];
for (const scene of this.scenes) {
const from = nodeMap.get(scene.id!);
@@ -171,19 +200,158 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
const y2 = to.y;
// t ∈ [0.25, 0.55] : labels plutot pres de la source, echelonnes.
const t = count === 1 ? 0.5 : 0.25 + (idx / (count - 1)) * 0.3;
const key = `${scene.id}|${b.targetSceneId}|${idx}`;
const offset = this.labelOffsets.get(key) ?? { dx: 0, dy: 0 };
edges.push({
key,
label: b.label,
x1, y1, x2, y2,
labelX: x1 + (x2 - x1) * t,
labelY: y1 + (y2 - y1) * t - 4
labelX: x1 + (x2 - x1) * t + offset.dx,
labelY: y1 + (y2 - y1) * t - 4 + offset.dy
});
});
}
this.nodes = nodes;
this.edges = edges;
this.svgWidth = Math.max(rowWidth + 40, 600);
this.svgHeight = (orphanLevel + 1) * (this.NODE_HEIGHT + this.V_SPACING) + 40;
}
/**
* Convertit des coordonnees ecran (PointerEvent) en coordonnees SVG via la CTM
* inverse. Necessaire car le SVG peut etre redimensionne par max-width.
*/
private toSvgCoords(evt: PointerEvent): { x: number; y: number } {
const svg = this.svgEl?.nativeElement;
if (!svg) return { x: evt.clientX, y: evt.clientY };
const pt = svg.createSVGPoint();
pt.x = evt.clientX;
pt.y = evt.clientY;
const ctm = svg.getScreenCTM();
if (!ctm) return { x: evt.clientX, y: evt.clientY };
const local = pt.matrixTransform(ctm.inverse());
return { x: local.x, y: local.y };
}
onPointerDown(evt: PointerEvent, node: GraphNode): void {
// Bouton gauche uniquement.
if (evt.button !== 0) return;
evt.preventDefault();
const { x, y } = this.toSvgCoords(evt);
this.draggingId = node.id;
this.dragOffsetX = x - node.x;
this.dragOffsetY = y - node.y;
this.dragMoved = false;
(evt.target as Element).setPointerCapture?.(evt.pointerId);
}
onPointerMove(evt: PointerEvent): void {
const { x, y } = this.toSvgCoords(evt);
if (this.draggingLabelKey) {
const edge = this.edges.find(e => e.key === this.draggingLabelKey);
if (!edge) return;
const newX = x - this.dragOffsetX;
const newY = y - this.dragOffsetY;
if (!this.dragMoved && Math.hypot(newX - edge.labelX, newY - edge.labelY) < this.DRAG_THRESHOLD) return;
this.dragMoved = true;
// Recalcule la position automatique courante puis stocke la difference,
// pour que l'offset reste valable meme apres deplacement d'un noeud.
const auto = this.autoLabelPosition(edge.key);
if (auto) {
this.labelOffsets.set(edge.key, { dx: newX - auto.x, dy: newY - auto.y });
}
edge.labelX = newX;
edge.labelY = newY;
return;
}
if (!this.draggingId) return;
const node = this.nodes.find(n => n.id === this.draggingId);
if (!node) return;
// Empeche le noeud de partir en coordonnees negatives : sinon il sort
// du viewport SVG et se fait clipper par le navigateur (le SVG a
// overflow: hidden par defaut quand on lui donne width/height explicites).
const newX = Math.max(0, x - this.dragOffsetX);
const newY = Math.max(0, y - this.dragOffsetY);
if (!this.dragMoved) {
const dx = newX - node.x;
const dy = newY - node.y;
if (Math.hypot(dx, dy) >= this.DRAG_THRESHOLD) this.dragMoved = true;
else return;
}
node.x = newX;
node.y = newY;
this.recomputeEdges();
this.fitSvgToNodes();
}
/**
* Recalcule la position "auto" (sans offset manuel) du label d'une arete
* a partir de sa cle. Utilise pour deriver le delta a stocker pendant le drag.
*/
private autoLabelPosition(key: string): { x: number; y: number } | null {
const [sourceId, targetId, idxStr] = key.split('|');
const idx = Number(idxStr);
const scene = this.scenes.find(s => s.id === sourceId);
if (!scene?.branches) return null;
const siblings = scene.branches.filter(b => this.nodes.some(n => n.id === b.targetSceneId));
const count = siblings.length;
if (idx >= count) return null;
const from = this.nodes.find(n => n.id === sourceId);
const to = this.nodes.find(n => n.id === targetId);
if (!from || !to) return null;
const x1 = from.x + this.NODE_WIDTH / 2;
const y1 = from.y + this.NODE_HEIGHT;
const x2 = to.x + this.NODE_WIDTH / 2;
const y2 = to.y;
const t = count === 1 ? 0.5 : 0.25 + (idx / (count - 1)) * 0.3;
return { x: x1 + (x2 - x1) * t, y: y1 + (y2 - y1) * t - 4 };
}
onLabelPointerDown(evt: PointerEvent, edge: GraphEdge): void {
if (evt.button !== 0) return;
// Empeche l'event de remonter au <g class="node"> ou au svg, sinon on
// declencherait aussi un drag de noeud.
evt.stopPropagation();
evt.preventDefault();
const { x, y } = this.toSvgCoords(evt);
this.draggingLabelKey = edge.key;
this.dragOffsetX = x - edge.labelX;
this.dragOffsetY = y - edge.labelY;
this.dragMoved = false;
(evt.target as Element).setPointerCapture?.(evt.pointerId);
}
/**
* Agrandit le SVG si un noeud s'approche du bord droit ou bas, pour eviter
* que le contenu deplace soit rogne. On ne reduit jamais en-dessous de la
* taille initiale du layout pour rester stable visuellement.
*/
private fitSvgToNodes(): void {
const margin = 40;
let maxX = 600;
let maxY = 200;
for (const n of this.nodes) {
if (n.x + this.NODE_WIDTH + margin > maxX) maxX = n.x + this.NODE_WIDTH + margin;
if (n.y + this.NODE_HEIGHT + margin > maxY) maxY = n.y + this.NODE_HEIGHT + margin;
}
if (maxX > this.svgWidth) this.svgWidth = maxX;
if (maxY > this.svgHeight) this.svgHeight = maxY;
}
onPointerUp(evt: PointerEvent): void {
if (this.draggingLabelKey) {
this.draggingLabelKey = null;
this.dragMoved = false;
(evt.target as Element).releasePointerCapture?.(evt.pointerId);
return;
}
if (!this.draggingId) return;
const id = this.draggingId;
const moved = this.dragMoved;
this.draggingId = null;
this.dragMoved = false;
(evt.target as Element).releasePointerCapture?.(evt.pointerId);
// Si le pointeur n'a pas reellement bouge, on traite comme un clic d'ouverture.
if (!moved) this.openScene(id);
}
private truncate(text: string): string {

View File

@@ -2,7 +2,10 @@
<header class="view-header">
<div>
<h1>{{ chapter.name }}</h1>
<h1>
<lucide-icon *ngIf="chapter.icon" [img]="resolveCampaignIcon(chapter.icon)" [size]="22" class="title-icon"></lucide-icon>
{{ chapter.name }}
</h1>
<p class="view-subtitle">Chapitre</p>
</div>
<div class="view-actions">

View File

@@ -4,6 +4,7 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Pencil, Network, Trash2 } from 'lucide-angular';
import { resolveCampaignIcon } from '../campaign-icons';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { PageService } from '../../services/page.service';
@@ -29,6 +30,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
readonly Pencil = Pencil;
readonly Network = Network;
readonly Trash2 = Trash2;
readonly resolveCampaignIcon = resolveCampaignIcon;
campaignId = '';
arcId = '';

View File

@@ -8,8 +8,9 @@
<form [formGroup]="form" (ngSubmit)="submit()" class="scene-form">
<div class="field">
<label>Nom de la scène *</label>
<label for="scene-create-name">Nom de la scène *</label>
<input
id="scene-create-name"
type="text"
formControlName="name"
placeholder="Ex: Arrivée au village"
@@ -18,14 +19,20 @@
</div>
<div class="field">
<label>Description</label>
<label for="scene-create-description">Description</label>
<textarea
id="scene-create-description"
formControlName="description"
placeholder="Décrivez la scène, les événements clés, les PNJ présents..."
rows="6">
</textarea>
</div>
<div class="field">
<label>Icône</label>
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
Créer la scène

View File

@@ -9,6 +9,8 @@ import { CharacterService } from '../../services/character.service';
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';
/**
* Écran de création d'une nouvelle scène rattachée à un chapitre.
@@ -17,11 +19,14 @@ import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper
@Component({
selector: 'app-scene-create',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule],
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, IconPickerComponent],
templateUrl: './scene-create.component.html',
styleUrls: ['./scene-create.component.scss']
})
export class SceneCreateComponent implements OnInit, OnDestroy {
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
selectedIcon: string | null = null;
form: FormGroup;
campaignId = '';
arcId = '';
@@ -84,7 +89,8 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
name: this.form.value.name,
description: this.form.value.description,
chapterId: this.chapterId,
order: this.existingSceneCount + 1
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]),
error: () => console.error('Erreur lors de la création de la scène')

View File

@@ -13,6 +13,14 @@
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
<button type="button" class="btn-danger" (click)="delete()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
<button type="button" class="btn-primary" (click)="submit()" [disabled]="form.invalid">
Sauvegarder
</button>
</div>
</div>
@@ -43,8 +51,9 @@
</div>
<div class="field">
<label>Titre de la scène *</label>
<label for="scene-edit-name">Titre de la scène *</label>
<input
id="scene-edit-name"
type="text"
formControlName="name"
placeholder="Ex: Arrivée au village"
@@ -53,29 +62,36 @@
</div>
<div class="field">
<label>Description courte *</label>
<label for="scene-edit-description">Description courte *</label>
<textarea
id="scene-edit-description"
formControlName="description"
placeholder="Résumé en une ou deux phrases de ce qui se passe..."
rows="3">
</textarea>
</div>
<div class="field">
<label>Icône</label>
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
</div>
<!-- Section : Contexte et ambiance -->
<app-expandable-section title="Contexte et ambiance" icon="📍" [initiallyOpen]="true">
<div class="field-row">
<div class="field">
<label>Lieu</label>
<input type="text" formControlName="location" placeholder="Ex: Taverne du Dragon d'Or" />
<label for="scene-edit-location">Lieu</label>
<input id="scene-edit-location" type="text" formControlName="location" placeholder="Ex: Taverne du Dragon d'Or" />
</div>
<div class="field">
<label>Moment</label>
<input type="text" formControlName="timing" placeholder="Ex: Soir, à la tombée de la nuit" />
<label for="scene-edit-timing">Moment</label>
<input id="scene-edit-timing" type="text" formControlName="timing" placeholder="Ex: Soir, à la tombée de la nuit" />
</div>
</div>
<div class="field">
<label>Ambiance et atmosphère</label>
<label for="scene-edit-atmosphere">Ambiance et atmosphère</label>
<textarea
id="scene-edit-atmosphere"
formControlName="atmosphere"
placeholder="Décrivez l'ambiance générale de la scène (sons, odeurs, lumière, émotions...)"
rows="4">
@@ -179,12 +195,13 @@
<!-- Section : Combat ou rencontre -->
<app-expandable-section title="Combat ou rencontre" icon="⚔️">
<div class="field">
<label>Difficulté estimée</label>
<input type="text" formControlName="combatDifficulty" placeholder="Ex: Moyenne, 3 gobelins niveau 2" />
<label for="scene-edit-combat-difficulty">Difficulté estimée</label>
<input id="scene-edit-combat-difficulty" type="text" formControlName="combatDifficulty" placeholder="Ex: Moyenne, 3 gobelins niveau 2" />
</div>
<div class="field">
<label>Ennemis et créatures</label>
<label for="scene-edit-enemies">Ennemis et créatures</label>
<textarea
id="scene-edit-enemies"
formControlName="enemies"
placeholder="Liste des ennemis présents dans cette scène..."
rows="4">
@@ -214,17 +231,6 @@
</small>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
Sauvegarder
</button>
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
<button type="button" class="btn-danger" (click)="delete()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</form>
</div>

View File

@@ -17,6 +17,8 @@ import { ExpandableSectionComponent } from '../../shared/expandable-section/expa
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';
/**
* Écran de détail/modification d'une Scène.
@@ -25,13 +27,15 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
@Component({
selector: 'app-scene-edit',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent],
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent],
templateUrl: './scene-edit.component.html',
styleUrls: ['./scene-edit.component.scss']
})
export class SceneEditComponent implements OnInit, OnDestroy {
readonly Trash2 = Trash2;
readonly Sparkles = Sparkles;
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
selectedIcon: string | null = null;
/** État drawer chat IA (b5.7 — intégration Campagne). */
chatOpen = false;
@@ -131,6 +135,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
this.loreId = loreId;
this.availablePages = pages;
this.relatedPageIds = [...(scene.relatedPageIds ?? [])];
this.selectedIcon = scene.icon ?? null;
this.illustrationImageIds = [...(scene.illustrationImageIds ?? [])];
this.mapImageIds = [...(scene.mapImageIds ?? [])];
this.siblingScenes = chapterScenes.filter(s => s.id !== this.sceneId);
@@ -184,7 +189,8 @@ export class SceneEditComponent implements OnInit, OnDestroy {
relatedPageIds: this.relatedPageIds,
illustrationImageIds: this.illustrationImageIds,
mapImageIds: this.mapImageIds,
branches: this.branches
branches: this.branches,
icon: this.selectedIcon
}).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', this.sceneId]),
error: () => console.error('Erreur lors de la sauvegarde')

View File

@@ -2,7 +2,10 @@
<header class="view-header">
<div>
<h1>{{ scene.name }}</h1>
<h1>
<lucide-icon *ngIf="scene.icon" [img]="resolveCampaignIcon(scene.icon)" [size]="22" class="title-icon"></lucide-icon>
{{ scene.name }}
</h1>
<p class="view-subtitle">Scène</p>
</div>
<div class="view-actions">

View File

@@ -4,6 +4,7 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
import { resolveCampaignIcon } from '../campaign-icons';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { PageService } from '../../services/page.service';
@@ -28,6 +29,7 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
export class SceneViewComponent implements OnInit, OnDestroy {
readonly Pencil = Pencil;
readonly Trash2 = Trash2;
readonly resolveCampaignIcon = resolveCampaignIcon;
campaignId = '';
arcId = '';

View File

@@ -0,0 +1,65 @@
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { catchError, throwError } from 'rxjs';
/**
* Detecte la perte de session demo (orchestrateur) via les codes 401/502 sur
* les appels /api/*, affiche un overlay puis force un rechargement de la page.
* Le reload renvoie l'utilisateur sur la page "Preparation" pour creer une
* nouvelle session sans qu'il ait a faire Ctrl+Shift+R.
*
* Cet interceptor est inerte en mode normal (non-demo) : si le backend natif
* renvoie un 401 legitime, ca declenche aussi le reload, ce qui est sans
* consequence puisqu'aucun flux d'auth utilisateur n'existe encore cote app.
*/
// Module-level flag : evite de declencher overlay + reload plusieurs fois si
// plusieurs appels echouent en parallele juste apres l'expiration.
let alreadyTriggered = false;
export const sessionExpiredInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
catchError((err) => {
const isApiCall = req.url.includes('/api/');
const isSessionLoss =
err instanceof HttpErrorResponse && (err.status === 401 || err.status === 502);
if (isApiCall && isSessionLoss && !alreadyTriggered) {
alreadyTriggered = true;
showExpiredOverlay();
setTimeout(() => window.location.reload(), 2500);
}
return throwError(() => err);
})
);
};
function showExpiredOverlay(): void {
const overlay = document.createElement('div');
overlay.setAttribute('data-session-expired', 'true');
overlay.style.cssText = [
'position:fixed', 'inset:0',
'background:rgba(26,22,37,0.96)',
'color:#e4def5',
'display:flex', 'flex-direction:column',
'align-items:center', 'justify-content:center',
'gap:1rem',
'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
'z-index:99999',
'text-align:center', 'padding:2rem',
].join(';');
overlay.innerHTML = `
<div style="font-size:1.5rem;color:#b794f4;">✦ Votre session démo a expiré</div>
<div style="color:#aaa0c5;max-width:420px;line-height:1.5;">
Une nouvelle session va être préparée automatiquement.<br>
Vos données précédentes ne sont pas conservées.
</div>
<div style="
width:32px;height:32px;margin-top:0.5rem;
border:3px solid rgba(183,148,244,0.2);
border-top-color:#b794f4;border-radius:50%;
animation:sex-spin 1s linear infinite;
"></div>
<style>@keyframes sex-spin{to{transform:rotate(360deg)}}</style>
`;
document.body.appendChild(overlay);
}

View File

@@ -11,8 +11,9 @@
<form [formGroup]="form" (ngSubmit)="submit()">
<div class="field">
<label>Nom de l'univers *</label>
<label for="lore-name">Nom de l'univers *</label>
<input
id="lore-name"
type="text"
formControlName="name"
placeholder="Ex: Royaume des Ombres, Cyberpunk 2157..."
@@ -21,8 +22,9 @@
</div>
<div class="field">
<label>Description</label>
<label for="lore-description">Description</label>
<textarea
id="lore-description"
formControlName="description"
placeholder="Décrivez brièvement votre univers, son ambiance, son genre..."
rows="5"

View File

@@ -21,12 +21,12 @@
<!-- ============ Header : mode édition inline ============ -->
<div class="detail-header edit-mode" *ngIf="editing">
<div class="field">
<label>Nom</label>
<input type="text" [(ngModel)]="editName" name="editName" required />
<label for="lore-detail-edit-name">Nom</label>
<input id="lore-detail-edit-name" type="text" [(ngModel)]="editName" name="editName" required />
</div>
<div class="field">
<label>Description</label>
<textarea [(ngModel)]="editDescription" name="editDescription" rows="3"></textarea>
<label for="lore-detail-edit-description">Description</label>
<textarea id="lore-detail-edit-description" [(ngModel)]="editDescription" name="editDescription" rows="3"></textarea>
</div>
<div class="header-actions">
<button type="button" class="btn-primary" (click)="saveEdit()" [disabled]="!editName.trim()">

View File

@@ -4,6 +4,7 @@ import {
BookOpen, Scroll, Wand2, Sparkles, TreePine, Mountain,
Ship, Flame, Star, Moon, Key, Globe, Compass, LucideIconData
} from 'lucide-angular';
import { CAMPAIGN_ICON_OPTIONS } from '../campaigns/campaign-icons';
/**
* Registre partagé d'icônes disponibles pour les dossiers (LoreNode).
@@ -46,8 +47,15 @@ export const LORE_ICON_OPTIONS: IconOption[] = [
/** Icône par défaut pour un dossier sans icône. */
export const DEFAULT_FOLDER_ICON: LucideIconData = Folder;
/** Résout une clé d'icône en LucideIconData. Fallback : icône dossier par défaut. */
/**
* Résout une clé d'icône en LucideIconData. Consulte LORE_ICON_OPTIONS puis
* CAMPAIGN_ICON_OPTIONS pour permettre à la sidebar partagée d'afficher
* indifféremment des icônes de dossiers (lore) ou d'arcs/chapitres/scènes
* (campagne). Fallback : icône dossier par défaut.
*/
export function resolveIcon(key: string | null | undefined): LucideIconData {
if (!key) return DEFAULT_FOLDER_ICON;
return LORE_ICON_OPTIONS.find(o => o.key === key)?.icon ?? DEFAULT_FOLDER_ICON;
const loreMatch = LORE_ICON_OPTIONS.find(o => o.key === key);
if (loreMatch) return loreMatch.icon;
return CAMPAIGN_ICON_OPTIONS.find(o => o.key === key)?.icon ?? DEFAULT_FOLDER_ICON;
}

View File

@@ -9,8 +9,8 @@
<!-- Titre -->
<div class="field">
<label>Titre de la page *</label>
<input type="text" formControlName="title" placeholder="Ex: Maître Eldrin, La Cité d'Argent..." />
<label for="page-title">Titre de la page *</label>
<input id="page-title" type="text" formControlName="title" placeholder="Ex: Maître Eldrin, La Cité d'Argent..." />
</div>
<!-- Template -->
@@ -42,10 +42,10 @@
<!-- Dossier de destination -->
<div class="field">
<label>Dossier de destination *</label>
<label for="page-node">Dossier de destination *</label>
<ng-container *ngIf="nodes.length; else emptyFolders">
<select formControlName="nodeId" [attr.disabled]="preselectedNodeId ? true : null">
<select id="page-node" formControlName="nodeId">
<option value="" disabled>Sélectionnez un dossier</option>
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option>
</select>

View File

@@ -87,7 +87,8 @@
&.selected {
border-color: #6c63ff;
background: #1e1c3a;
background: #2a2558;
box-shadow: 0 0 0 1px #6c63ff, 0 0 12px rgba(108, 99, 255, 0.35);
}
.template-card-head {

View File

@@ -88,10 +88,33 @@ export class PageCreateComponent implements OnInit, OnDestroy {
this.templates = data.templates;
this.layoutService.show(buildLoreSidebarConfig(data));
// Si nodeId fourni par l'URL, on verrouille la valeur du formulaire.
// Si nodeId fourni par l'URL, on fige la valeur ET on désactive le
// contrôle de formulaire (FormControl.disable, pas attr.disabled qui
// serait cosmétique). La valeur reste incluse dans les submits.
if (this.preselectedNodeId) {
this.form.patchValue({ nodeId: this.preselectedNodeId });
this.form.get('nodeId')?.disable();
this.autoSelectTemplateForNode(this.preselectedNodeId);
} else {
// Pas de nodeId dans l'URL : le <select> affiche visuellement la
// première option mais la valeur du FormControl reste ''. On tente
// l'auto-sélection inverse : si un seul template a un defaultNodeId
// qui pointe sur un dossier existant, on le sélectionne et on
// pré-remplit le dossier — sinon on laisse l'utilisateur choisir.
const validNodeIds = new Set(this.nodes.map(n => n.id));
const candidates = this.templates.filter(
t => t.defaultNodeId && validNodeIds.has(t.defaultNodeId)
);
if (candidates.length === 1) {
const tpl = candidates[0];
this.selectedTemplateId = tpl.id!;
this.form.patchValue({ nodeId: tpl.defaultNodeId });
}
}
this.form.get('nodeId')?.valueChanges.subscribe(nodeId => {
this.autoSelectTemplateForNode(nodeId);
});
this.restoreDraft();
});
@@ -134,6 +157,18 @@ export class PageCreateComponent implements OnInit, OnDestroy {
} catch { /* JSON corrompu : on ignore */ }
}
/**
* Auto-sélection du template dont defaultNodeId === nodeId courant.
* Ne fait rien si l'utilisateur a déjà choisi un template manuellement
* (on ne veut pas écraser un choix explicite).
*/
private autoSelectTemplateForNode(nodeId: string | null | undefined): void {
if (!nodeId) return;
if (this.selectedTemplateId) return;
const matching = this.templates.find(t => t.defaultNodeId === nodeId);
if (matching) this.selectedTemplateId = matching.id!;
}
selectTemplate(template: Template): void {
this.selectedTemplateId = template.id!;
// Si pas de noeud pré-choisi par l'URL, on pré-remplit avec le defaultNodeId du template.
@@ -152,7 +187,7 @@ export class PageCreateComponent implements OnInit, OnDestroy {
submit(): void {
if (!this.canSubmit) return;
const raw = this.form.value;
const raw = this.form.getRawValue();
this.pageService.create({
loreId: this.loreId,
nodeId: raw.nodeId,
@@ -206,7 +241,7 @@ export class PageCreateComponent implements OnInit, OnDestroy {
return;
}
this.wizardError = null;
const raw = this.form.value;
const raw = this.form.getRawValue();
// Le backend POST /api/pages ne prend pas `values` — on crée d'abord la
// coquille, puis on PUT immédiatement avec les valeurs extraites.
// 2 roundtrips, mais zéro modification backend nécessaire.

View File

@@ -11,20 +11,20 @@
<div class="col-left">
<div class="field">
<label>Nom du template *</label>
<input type="text" formControlName="name" placeholder="Ex: Auberge, Artefact, Monstre..." />
<label for="template-name">Nom du template *</label>
<input id="template-name" type="text" formControlName="name" placeholder="Ex: Auberge, Artefact, Monstre..." />
</div>
<div class="field">
<label>Description</label>
<textarea formControlName="description" rows="4" placeholder="À quoi sert ce template ?"></textarea>
<label for="template-description">Description</label>
<textarea id="template-description" formControlName="description" rows="4" placeholder="À quoi sert ce template ?"></textarea>
</div>
<div class="field">
<label>Dossier par défaut *</label>
<label for="template-default-node">Dossier par défaut *</label>
<ng-container *ngIf="nodes.length; else emptyFolders">
<select formControlName="defaultNodeId">
<select id="template-default-node" formControlName="defaultNodeId">
<option value="" disabled>Sélectionnez un dossier</option>
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option>
</select>

View File

@@ -42,7 +42,8 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
*/
fields: TemplateField[] = [
{ name: 'Nom', type: 'TEXT' },
{ name: 'Description', type: 'TEXT' }
{ name: 'Description', type: 'TEXT' },
{ name: 'Illustration', type: 'IMAGE', layout: 'GALLERY' }
];
/** Valeur courante de l'input d'ajout de champ (non binding direct pour reset facile). */
newFieldName = '';

View File

@@ -17,13 +17,13 @@
<div class="col-left">
<div class="field">
<label>Nom</label>
<input type="text" formControlName="name" />
<label for="template-edit-name">Nom</label>
<input id="template-edit-name" type="text" formControlName="name" />
</div>
<div class="field">
<label>Dossier par défaut</label>
<select formControlName="defaultNodeId">
<label for="template-edit-default-node">Dossier par défaut</label>
<select id="template-edit-default-node" formControlName="defaultNodeId">
<option value="">-- Aucun --</option>
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option>
</select>
@@ -31,8 +31,8 @@
</div>
<div class="field">
<label>Description</label>
<textarea formControlName="description" rows="6"></textarea>
<label for="template-edit-description">Description</label>
<textarea id="template-edit-description" formControlName="description" rows="6"></textarea>
</div>
</div>
@@ -103,7 +103,7 @@
<option value="TEXT">Texte</option>
<option value="IMAGE">Image</option>
</select>
<button type="button" class="btn-add" (click)="addField()">
<button type="button" class="btn-add" (click)="addField()" title="Ajouter le champ">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
</button>
</div>

View File

@@ -198,18 +198,7 @@
&::placeholder { color: #6b7280; }
}
&.add-row {
margin-top: 0.25rem;
border: 1px dashed #2a2a3d;
border-radius: 6px;
padding: 0;
input {
border: none;
background: transparent;
&:focus { border: none; }
}
}
&.add-row { margin-top: 0.5rem; }
.reorder-stack {
display: flex;

View File

@@ -29,6 +29,9 @@ export interface Arc {
order?: number;
chapterCount?: number;
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS). */
icon?: string | null;
// Champs narratifs enrichis
themes?: string;
stakes?: string;
@@ -52,6 +55,7 @@ export interface ArcCreate {
description?: string;
campaignId: string;
order: number;
icon?: string | null;
themes?: string;
stakes?: string;
@@ -70,6 +74,7 @@ export interface Chapter {
description?: string;
arcId: string;
order?: number;
icon?: string | null;
// Champs narratifs enrichis
gmNotes?: string;
@@ -86,6 +91,7 @@ export interface ChapterCreate {
description?: string;
arcId: string;
order: number;
icon?: string | null;
gmNotes?: string;
playerObjectives?: string;
@@ -112,6 +118,7 @@ export interface Scene {
description?: string; // = Description courte dans l'UI
chapterId: string;
order?: number;
icon?: string | null;
// Champs narratifs enrichis
location?: string;
@@ -136,6 +143,7 @@ export interface SceneCreate {
description?: string;
chapterId: string;
order: number;
icon?: string | null;
location?: string;
timing?: string;

View File

@@ -60,7 +60,8 @@ export class CampaignService {
// ========== ARC ==========
getArcs(campaignId: string): Observable<Arc[]> {
return this.http.get<Arc[]>(`/api/arcs/campaign/${campaignId}`);
const params = new HttpParams().set('campaignId', campaignId);
return this.http.get<Arc[]>('/api/arcs', { params });
}
getArcById(id: string): Observable<Arc> {
@@ -85,7 +86,8 @@ export class CampaignService {
// ========== CHAPTER ==========
getChapters(arcId: string): Observable<Chapter[]> {
return this.http.get<Chapter[]>(`/api/chapters/arc/${arcId}`);
const params = new HttpParams().set('arcId', arcId);
return this.http.get<Chapter[]>('/api/chapters', { params });
}
getChapterById(id: string): Observable<Chapter> {
@@ -110,7 +112,8 @@ export class CampaignService {
// ========== SCENE ==========
getScenes(chapterId: string): Observable<Scene[]> {
return this.http.get<Scene[]>(`/api/scenes/chapter/${chapterId}`);
const params = new HttpParams().set('chapterId', chapterId);
return this.http.get<Scene[]>('/api/scenes', { params });
}
getSceneById(id: string): Observable<Scene> {

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { shareReplay, tap } from 'rxjs/operators';
import { Lore, LoreCreate, LoreNode, LoreNodeCreate } from './lore.model';
/** Compte des entités qui seront supprimées en cascade avec un dossier. */
@@ -23,6 +24,11 @@ export interface LoreDeletionImpact {
/**
* Service HTTP pour la gestion des Lores.
* Port de sortie vers le Backend Java (Architecture Hexagonale).
*
* Les lectures agrégées par la sidebar (getAllLores, getLoreById, getLoreNodes)
* sont mises en cache via `shareReplay(1)` pour éviter 5 fetchs redondants à
* chaque navigation interne. Toute mutation (create/update/delete) invalide
* l'ensemble du cache du service.
*/
@Injectable({
providedIn: 'root'
@@ -31,26 +37,51 @@ export class LoreService {
private apiUrl = '/api/lores';
private nodesUrl = '/api/lore-nodes';
private allLoresCache: Observable<Lore[]> | null = null;
private loreByIdCache = new Map<string, Observable<Lore>>();
private nodesByLoreIdCache = new Map<string, Observable<LoreNode[]>>();
constructor(private http: HttpClient) {}
/** Vide tous les caches de lecture — appelé après toute mutation. */
private invalidate(): void {
this.allLoresCache = null;
this.loreByIdCache.clear();
this.nodesByLoreIdCache.clear();
}
getAllLores(): Observable<Lore[]> {
return this.http.get<Lore[]>(this.apiUrl);
if (!this.allLoresCache) {
this.allLoresCache = this.http.get<Lore[]>(this.apiUrl).pipe(
tap({ error: () => (this.allLoresCache = null) }),
shareReplay(1)
);
}
return this.allLoresCache;
}
getLoreById(id: string): Observable<Lore> {
return this.http.get<Lore>(`${this.apiUrl}/${id}`);
let obs = this.loreByIdCache.get(id);
if (!obs) {
obs = this.http.get<Lore>(`${this.apiUrl}/${id}`).pipe(
tap({ error: () => this.loreByIdCache.delete(id) }),
shareReplay(1)
);
this.loreByIdCache.set(id, obs);
}
return obs;
}
createLore(lore: LoreCreate): Observable<Lore> {
return this.http.post<Lore>(this.apiUrl, lore);
return this.http.post<Lore>(this.apiUrl, lore).pipe(tap(() => this.invalidate()));
}
updateLore(id: string, lore: LoreCreate): Observable<Lore> {
return this.http.put<Lore>(`${this.apiUrl}/${id}`, lore);
return this.http.put<Lore>(`${this.apiUrl}/${id}`, lore).pipe(tap(() => this.invalidate()));
}
deleteLore(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
return this.http.delete<void>(`${this.apiUrl}/${id}`).pipe(tap(() => this.invalidate()));
}
getLoreDeletionImpact(id: string): Observable<LoreDeletionImpact> {
@@ -58,7 +89,15 @@ export class LoreService {
}
getLoreNodes(loreId: string): Observable<LoreNode[]> {
return this.http.get<LoreNode[]>(`${this.nodesUrl}?loreId=${loreId}`);
let obs = this.nodesByLoreIdCache.get(loreId);
if (!obs) {
obs = this.http.get<LoreNode[]>(`${this.nodesUrl}?loreId=${loreId}`).pipe(
tap({ error: () => this.nodesByLoreIdCache.delete(loreId) }),
shareReplay(1)
);
this.nodesByLoreIdCache.set(loreId, obs);
}
return obs;
}
getLoreNodeById(id: string): Observable<LoreNode> {
@@ -66,16 +105,16 @@ export class LoreService {
}
createLoreNode(node: LoreNodeCreate): Observable<LoreNode> {
return this.http.post<LoreNode>(this.nodesUrl, node);
return this.http.post<LoreNode>(this.nodesUrl, node).pipe(tap(() => this.invalidate()));
}
/** PUT complet — envoie l'objet entier au backend (qui attend un LoreNodeDTO). */
updateLoreNode(id: string, node: LoreNode): Observable<LoreNode> {
return this.http.put<LoreNode>(`${this.nodesUrl}/${id}`, node);
return this.http.put<LoreNode>(`${this.nodesUrl}/${id}`, node).pipe(tap(() => this.invalidate()));
}
deleteLoreNode(id: string): Observable<void> {
return this.http.delete<void>(`${this.nodesUrl}/${id}`);
return this.http.delete<void>(`${this.nodesUrl}/${id}`).pipe(tap(() => this.invalidate()));
}
getLoreNodeDeletionImpact(id: string): Observable<LoreNodeDeletionImpact> {

View File

@@ -1,23 +1,40 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { map, shareReplay, tap } from 'rxjs/operators';
import { Page, PageCreate } from './page.model';
/**
* Service HTTP pour la gestion des Pages.
* Port de sortie du Frontend vers le Backend Java (/api/pages).
*
* `getByLoreId` est cache via shareReplay(1) — toute mutation
* (create/update/delete) invalide l'ensemble du cache.
*/
@Injectable({ providedIn: 'root' })
export class PageService {
private apiUrl = '/api/pages';
private byLoreIdCache = new Map<string, Observable<Page[]>>();
constructor(private http: HttpClient) {}
private invalidate(): void {
this.byLoreIdCache.clear();
}
/** Toutes les pages d'un Lore (flat, pour répartir ensuite par nodeId). */
getByLoreId(loreId: string): Observable<Page[]> {
let obs = this.byLoreIdCache.get(loreId);
if (!obs) {
const params = new HttpParams().set('loreId', loreId);
return this.http.get<Page[]>(this.apiUrl, { params });
obs = this.http.get<Page[]>(this.apiUrl, { params }).pipe(
tap({ error: () => this.byLoreIdCache.delete(loreId) }),
shareReplay(1)
);
this.byLoreIdCache.set(loreId, obs);
}
return obs;
}
/** Toutes les pages d'un noeud donné. */
@@ -31,15 +48,15 @@ export class PageService {
}
create(payload: PageCreate): Observable<Page> {
return this.http.post<Page>(this.apiUrl, payload);
return this.http.post<Page>(this.apiUrl, payload).pipe(tap(() => this.invalidate()));
}
update(id: string, page: Page): Observable<Page> {
return this.http.put<Page>(`${this.apiUrl}/${id}`, page);
return this.http.put<Page>(`${this.apiUrl}/${id}`, page).pipe(tap(() => this.invalidate()));
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
return this.http.delete<void>(`${this.apiUrl}/${id}`).pipe(tap(() => this.invalidate()));
}
search(q: string): Observable<Page[]> {

View File

@@ -1,22 +1,40 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { shareReplay, tap } from 'rxjs/operators';
import { Template, TemplateCreate } from './template.model';
/**
* Service HTTP pour la gestion des Templates.
* Port de sortie du Frontend vers le Backend Java (/api/templates).
*
* `getByLoreId` est cache via shareReplay(1) — toute mutation
* (create/update/delete) invalide l'ensemble du cache.
*/
@Injectable({ providedIn: 'root' })
export class TemplateService {
private apiUrl = '/api/templates';
private byLoreIdCache = new Map<string, Observable<Template[]>>();
constructor(private http: HttpClient) {}
private invalidate(): void {
this.byLoreIdCache.clear();
}
/** Tous les templates d'un Lore (alimente le panneau sidebar). */
getByLoreId(loreId: string): Observable<Template[]> {
let obs = this.byLoreIdCache.get(loreId);
if (!obs) {
const params = new HttpParams().set('loreId', loreId);
return this.http.get<Template[]>(this.apiUrl, { params });
obs = this.http.get<Template[]>(this.apiUrl, { params }).pipe(
tap({ error: () => this.byLoreIdCache.delete(loreId) }),
shareReplay(1)
);
this.byLoreIdCache.set(loreId, obs);
}
return obs;
}
getById(id: string): Observable<Template> {
@@ -24,15 +42,15 @@ export class TemplateService {
}
create(payload: TemplateCreate): Observable<Template> {
return this.http.post<Template>(this.apiUrl, payload);
return this.http.post<Template>(this.apiUrl, payload).pipe(tap(() => this.invalidate()));
}
update(id: string, template: Template): Observable<Template> {
return this.http.put<Template>(`${this.apiUrl}/${id}`, template);
return this.http.put<Template>(`${this.apiUrl}/${id}`, template).pipe(tap(() => this.invalidate()));
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
return this.http.delete<void>(`${this.apiUrl}/${id}`).pipe(tap(() => this.invalidate()));
}
search(q: string): Observable<Template[]> {

View File

@@ -0,0 +1,31 @@
.icon-grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
background: #1f2937;
border: 1px solid #374151;
border-radius: 8px;
padding: 0.75rem;
}
.icon-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: #9ca3af;
cursor: pointer;
transition: all 0.15s;
&:hover { background: #374151; color: white; }
&.selected {
background: #1e1b4b;
border-color: #6c63ff;
color: #a5b4fc;
}
}

View File

@@ -0,0 +1,54 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, LucideIconData } from 'lucide-angular';
export interface IconPickerOption {
key: string;
icon: LucideIconData;
}
/**
* Petit composant reutilisable : grille d'icones cliquables avec selection unique.
* Utilise par les formulaires de creation / edition d'arcs, chapitres et scenes
* (ainsi que potentiellement les dossiers du Lore plus tard).
*
* Usage :
* <app-icon-picker [options]="iconOptions" [(selected)]="selectedIcon"></app-icon-picker>
*
* Le composant est purement presentationnel : il n'interroge pas le registre
* d'icones lui-meme — l'appelant lui passe la banque a afficher.
*/
@Component({
selector: 'app-icon-picker',
standalone: true,
imports: [CommonModule, LucideAngularModule],
template: `
<div class="icon-grid">
<button
type="button"
class="icon-btn"
*ngFor="let option of options"
[class.selected]="selected === option.key"
[attr.aria-pressed]="selected === option.key"
[title]="option.key"
(click)="pick(option.key)">
<lucide-icon [img]="option.icon" [size]="18"></lucide-icon>
</button>
</div>
`,
styleUrls: ['./icon-picker.component.scss']
})
export class IconPickerComponent {
@Input() options: IconPickerOption[] = [];
@Input() selected: string | null = null;
/** Permet le binding two-way `[(selected)]`. */
@Output() selectedChange = new EventEmitter<string | null>();
/** Si true, recliquer sur l'icone actuellement selectionnee la deselectionne. */
@Input() allowDeselect = true;
pick(key: string): void {
const next = this.allowDeselect && this.selected === key ? null : key;
this.selected = next;
this.selectedChange.emit(next);
}
}

View File

@@ -7,7 +7,7 @@
flex-direction: column;
padding: 1.25rem 0.75rem;
gap: 0.75rem;
overflow-y: auto;
overflow: hidden;
position: relative;
// Pas de transition sur la largeur : sinon le drag de resize "traîne" derrière la souris.
// L'animation d'expand/collapse est gérée uniquement par la classe .collapsed ci-dessous.

Some files were not shown because too many files have changed in this diff Show More