14 Commits

Author SHA1 Message Date
88278bd1dd Fix : problème d'ascenseur en bas de la page au niveau des templates
Some checks failed
Build & Push Images / build (brain) (push) Successful in 1m1s
Build & Push Images / build (core) (push) Successful in 1m32s
E2E Tests / e2e (push) Failing after 4m13s
Build & Push Images / build (web) (push) Successful in 1m53s
Sélection du template par défaut lors de la création d'une page en fonction du dossier
Passage v0.6.2
2026-04-25 01:39:05 +02:00
d24d6459a0 Ajout de test, correctif d'un problème d'horloge pour le workflow gitea actions pour le e2e
Some checks failed
E2E Tests / e2e (push) Failing after 3m33s
2026-04-25 00:51:32 +02:00
4b866e5212 Fix workflow gitea action pour e2e (tests automatisés via playwright) + correction d'une incohérence dans l'API coté java. Ajout d'autres tests utilisateur
Some checks failed
E2E Tests / e2e (push) Failing after 2m31s
2026-04-25 00:45:04 +02:00
6c6bd20f0d Mise en place de tests utilisateurs avec playwright pour la partie angular + corrections au niveau des labels avec for et id pour cliquer dessus
Some checks failed
E2E Tests / e2e (push) Failing after 5m30s
2026-04-25 00:25:53 +02:00
2764228abf Fix rate limit derriere Cloudflare + CORS sur POST demo 2026-04-24 08:55:40 +02:00
f95d69c915 Fix CORS 403 sur POST : passer APP_CORS_ALLOWED_ORIGINS au core démo 2026-04-24 08:46:26 +02:00
70351e9d9a Remplace docker SDK par appels HTTP directs (zero deps)
All checks were successful
Build & Push Images / build (brain) (push) Successful in 52s
Build & Push Images / build (core) (push) Successful in 1m28s
Build & Push Images / build (web) (push) Successful in 1m34s
2026-04-24 07:39:49 +02:00
ff4905126d Docker SDK v28 pour resoudre les conflits transitifs 2026-04-24 07:33:48 +02:00
0e5b5a7de4 Correction d'une dépendance go 2026-04-24 07:30:20 +02:00
c8c032336b Mise à jour du dockerfile suite à une dépendance trop ancienne sur go 2026-04-24 07:26:42 +02:00
dda27e55fc Orchestrateur go pour lancer la démo et mettre en place plusieurs instances docker 2026-04-23 17:49:26 +02:00
83ac67471e Changement dans la config pour éviter les url en dur + mise en place d'un mode démo 2026-04-23 17:15:08 +02:00
e3c8232e38 Version 0.6.1
All checks were successful
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m19s
Build & Push Images / build (web) (push) Successful in 1m31s
2026-04-23 14:36:09 +02:00
a4df9fc759 Ajout des personnage dans la sidebar de la campagne 2026-04-23 14:34:07 +02:00
97 changed files with 3553 additions and 187 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.0",
version="0.6.2",
)

View File

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

View File

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

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

@@ -0,0 +1,30 @@
package com.loremind.infrastructure.web.controller;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* Expose la configuration publique consommee par le frontend au demarrage.
* Activer le mode demo via la variable d'env DEMO_MODE=true : le front
* masque alors Settings / Export VTT, et les endpoints sensibles sont
* verrouilles cote serveur (cf. SettingsController).
*/
@RestController
@RequestMapping("/api/config")
public class ConfigController {
private final boolean demoMode;
public ConfigController(@Value("${app.demo-mode:false}") boolean demoMode) {
this.demoMode = demoMode;
}
@GetMapping
public Map<String, Object> getPublicConfig() {
return Map.of("demoMode", demoMode);
}
}

View File

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

@@ -4,6 +4,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
@@ -13,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.server.ResponseStatusException;
import java.util.Map;
@@ -32,20 +34,25 @@ public class SettingsController {
private final RestTemplate restTemplate;
private final String brainBaseUrl;
private final boolean demoMode;
public SettingsController(RestTemplate restTemplate,
@Value("${brain.base-url}") String brainBaseUrl) {
@Value("${brain.base-url}") String brainBaseUrl,
@Value("${app.demo-mode:false}") boolean demoMode) {
this.restTemplate = restTemplate;
this.brainBaseUrl = brainBaseUrl;
this.demoMode = demoMode;
}
@GetMapping
public ResponseEntity<Map<String, Object>> getSettings() {
guardDemoMode();
return forward(HttpMethod.GET, "/settings", null);
}
@PutMapping
public ResponseEntity<Map<String, Object>> updateSettings(@RequestBody Map<String, Object> patch) {
guardDemoMode();
return forward(HttpMethod.PUT, "/settings", patch);
}
@@ -64,6 +71,12 @@ public class SettingsController {
return forward(HttpMethod.GET, "/models/onemin", null);
}
private void guardDemoMode() {
if (demoMode) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Settings disabled in demo mode");
}
}
@SuppressWarnings({"rawtypes", "unchecked"})
private ResponseEntity<Map<String, Object>> forward(HttpMethod method, String path, Object body) {
HttpHeaders headers = new HttpHeaders();

View File

@@ -21,13 +21,13 @@ spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
# Configuration CORS pour autoriser le Frontend Angular
spring.web.cors.allowed-origins=http://localhost:4200
spring.web.cors.allowed-origins=${CORS_ALLOWED_ORIGINS:http://localhost:4200}
spring.web.cors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS
spring.web.cors.allowed-headers=*
spring.web.cors.allow-credentials=true
# Configuration du Brain (service IA Python)
brain.base-url=http://localhost:8000
brain.base-url=${BRAIN_BASE_URL:http://localhost:8000}
brain.timeout-seconds=120
# Secret partage Core <-> Brain (auth inter-service via entete X-Internal-Secret).
@@ -50,3 +50,7 @@ minio.bucket=${MINIO_BUCKET:loremind-images}
# Limites d'upload d'images (MB)
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB
# Mode demo : masque Settings/Export cote front et bloque les PUT /api/settings
# cote serveur. Activer via DEMO_MODE=true sur les deploiements publics.
app.demo-mode=${DEMO_MODE:false}

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

27
demo/.env.example Normal file
View File

@@ -0,0 +1,27 @@
# Copie en .env sur le serveur (jamais commite).
# Registre et tag des images core / brain a spawner par session.
REGISTRY=git.igmlcreation.fr
TAG=latest
# Secret partage entre core et brain (genere aleatoirement au build de chaque
# session, mais un defaut est utile pour les checks de sante au boot).
BRAIN_INTERNAL_SECRET_DEFAULT=change-me-on-server
# Capacite
MAX_SESSIONS=10
SESSION_TTL_MINUTES=20
# Rate limiting : 1 creation de session par IP par fenetre (secondes).
RATE_LIMIT_WINDOW_SECONDS=60
# Limites par conteneur de session (Docker API)
CORE_MEMORY_MB=700
BRAIN_MEMORY_MB=300
POSTGRES_MEMORY_MB=200
# Nom du reseau Docker externe Traefik (doit exister avant docker compose up)
TRAEFIK_NETWORK=traefik
# Domaine expose par Traefik
DEMO_HOST=loremind-demo.igmlcreation.fr

2
demo/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.env
*.log

46
demo/README.md Normal file
View File

@@ -0,0 +1,46 @@
# Demo publique LoreMind
Deploiement d'une instance de demo ephemere pour `loremind-demo.igmlcreation.fr`.
## Principe
Chaque visiteur recoit un environnement isole spawne a la volee, detruit apres
un court delai d'inactivite. Les donnees ne sont jamais persistees.
Le mode demo (variable d'env `DEMO_MODE=true` sur le core) masque les ecrans
de configuration qui n'ont pas de sens en vitrine.
## Deploiement
Prerequis :
- Reseau Traefik existant cote host
- Images `core` et `brain` pushees au registre
```bash
cp .env.example .env
# Ajuster .env
docker compose -f docker-compose.infra.yml up -d --build
```
Premier build : 5-10 min. Suivants : incrementaux.
## Mise a jour
```bash
docker compose -f docker-compose.infra.yml pull
docker compose -f docker-compose.infra.yml up -d --build
```
Les sessions en cours sont tuees au redemarrage.
## Observations
- `docker logs loremind-demo-orchestrator -f`
- `docker ps --filter "name=demo-"`
## Desactiver
```bash
docker compose -f docker-compose.infra.yml down
docker ps -q --filter "name=demo-" | xargs -r docker rm -f
```

View File

@@ -0,0 +1,80 @@
# ==========================================================================
# LoreMind Demo - Infra permanente
# --------------------------------------------------------------------------
# - dockerproxy : expose un subset restreint de l'API Docker a l'orchestrateur
# (lecture seule sauf containers/images/networks). Remplace le mount direct
# de /var/run/docker.sock : meme avec RCE sur l'orchestrateur, un attaquant
# ne peut pas exec sur l'hote, creer des volumes, ni lire le daemon.
# - orchestrator : sert l'Angular et proxy les /api/* vers les sessions.
#
# Les conteneurs de session sont crees dynamiquement par l'orchestrateur.
# ==========================================================================
services:
dockerproxy:
image: tecnativa/docker-socket-proxy:latest
container_name: loremind-demo-dockerproxy
restart: unless-stopped
environment:
# Minimum requis par l'orchestrateur.
CONTAINERS: 1
IMAGES: 1
NETWORKS: 1
POST: 1
# Tout le reste reste a 0 (defaut) : pas d'EXEC, VOLUMES, BUILD, AUTH,
# SYSTEM, INFO, SWARM, SECRETS, CONFIGS, NODES, etc.
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- socket-proxy
# Pas de ports exposes : accessible uniquement via le reseau socket-proxy.
orchestrator:
container_name: loremind-demo-orchestrator
depends_on:
- dockerproxy
build:
context: ../
dockerfile: demo/orchestrator/Dockerfile
restart: unless-stopped
environment:
# L'orchestrateur parle a dockerproxy au lieu du socket direct.
DOCKER_HOST: tcp://dockerproxy:2375
REGISTRY: ${REGISTRY:-git.igmlcreation.fr}
TAG: ${TAG:-latest}
MAX_SESSIONS: ${MAX_SESSIONS:-10}
SESSION_TTL_MINUTES: ${SESSION_TTL_MINUTES:-20}
CORE_MEMORY_MB: ${CORE_MEMORY_MB:-700}
BRAIN_MEMORY_MB: ${BRAIN_MEMORY_MB:-300}
POSTGRES_MEMORY_MB: ${POSTGRES_MEMORY_MB:-200}
SESSIONS_NETWORK: loremind-demo-sessions
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
- socket-proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.loremind-demo.rule=Host(`${DEMO_HOST:-loremind-demo.igmlcreation.fr}`)"
- "traefik.http.routers.loremind-demo.entrypoints=websecure"
- "traefik.http.routers.loremind-demo.tls.certresolver=letsencrypt"
- "traefik.http.services.loremind-demo.loadbalancer.server.port=80"
networks:
traefik:
external: true
name: ${TRAEFIK_NETWORK:-traefik}
sessions:
# Reseau interne pour les trios de session. Pas d'acces Internet direct
# (sauf via le DNS Docker), pas expose au host.
name: loremind-demo-sessions
driver: bridge
socket-proxy:
# Reseau prive entre dockerproxy et orchestrateur. Isole du reste.
name: loremind-demo-socket-proxy
driver: bridge
internal: true

View File

@@ -0,0 +1,32 @@
# syntax=docker/dockerfile:1.6
# Build context attendu : racine du repo LoreMind.
# Appele depuis demo/docker-compose.infra.yml avec context: ../
# --- Etage 1 : build Angular statique ---
FROM node:20-alpine AS web-build
WORKDIR /build
COPY web/package*.json ./
RUN npm ci
COPY web/ .
RUN npm run build -- --configuration production
# --- Etage 2 : build orchestrateur Go ---
# go 1.25+ requis par une dependance transitive de github.com/docker/docker
# (otelhttp v0.68+ impose cette version minimale).
FROM golang:1.25-alpine AS go-build
WORKDIR /src
COPY demo/orchestrator/ ./
# go mod tidy resout le go.sum au build pour eviter d'avoir a le committer.
RUN go mod tidy && CGO_ENABLED=0 go build -o /orchestrator .
# --- Etage final : runtime minimal ---
FROM alpine:3.20
RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=go-build /orchestrator /app/orchestrator
COPY --from=web-build /build/dist/web /app/static
COPY demo/orchestrator/preparing.html /app/preparing.html
EXPOSE 80
ENV STATIC_DIR=/app/static \
PREPARING_PAGE=/app/preparing.html
ENTRYPOINT ["/app/orchestrator"]

View File

@@ -0,0 +1,68 @@
package main
import (
"log"
"os"
"strconv"
"time"
)
// Config centralise les parametres lus depuis les variables d'env au boot.
type Config struct {
Registry string
Tag string
MaxSessions int
SessionTTL time.Duration
CoreMemoryBytes int64
BrainMemoryBytes int64
PostgresMemoryBytes int64
SessionsNetwork string
BrainSecretDefault string
StaticDir string
PreparingPage string
RateLimitWindow time.Duration
MaxBodyBytes int64
DemoHost string
}
func loadConfig() *Config {
return &Config{
Registry: envStr("REGISTRY", "git.igmlcreation.fr"),
Tag: envStr("TAG", "latest"),
MaxSessions: envInt("MAX_SESSIONS", 10),
SessionTTL: time.Duration(envInt("SESSION_TTL_MINUTES", 20)) * time.Minute,
CoreMemoryBytes: int64(envInt("CORE_MEMORY_MB", 700)) * 1024 * 1024,
BrainMemoryBytes: int64(envInt("BRAIN_MEMORY_MB", 300)) * 1024 * 1024,
PostgresMemoryBytes: int64(envInt("POSTGRES_MEMORY_MB", 200)) * 1024 * 1024,
SessionsNetwork: envStr("SESSIONS_NETWORK", "loremind-demo-sessions"),
BrainSecretDefault: envStr("BRAIN_INTERNAL_SECRET_DEFAULT", "change-me"),
StaticDir: envStr("STATIC_DIR", "/app/static"),
PreparingPage: envStr("PREPARING_PAGE", "/app/preparing.html"),
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"),
}
}
func envStr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func envInt(key string, def int) int {
v := os.Getenv(key)
if v == "" {
return def
}
i, err := strconv.Atoi(v)
if err != nil {
log.Printf("warning: env %s=%q not a number, using default %d", key, v, def)
return def
}
return i
}

339
demo/orchestrator/docker.go Normal file
View File

@@ -0,0 +1,339 @@
package main
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// DockerClient parle a l'API Engine Docker en HTTP brut via le dockerproxy.
// Pas de SDK externe : evite les conflits de versions transitives qui
// rendaient github.com/docker/docker v27/v28 ininstallable proprement.
//
// L'API Engine v1.43 est exposee par Docker Engine 24+ (et le dockerproxy
// la supporte sans config supplementaire).
type DockerClient struct {
baseURL string
http *http.Client
}
func newDockerClient() (*DockerClient, error) {
base := os.Getenv("DOCKER_HOST")
if base == "" {
return nil, fmt.Errorf("DOCKER_HOST non defini (attendu : tcp://dockerproxy:2375)")
}
// tcp://host:port -> http://host:port (le dockerproxy parle HTTP en clair).
base = strings.Replace(base, "tcp://", "http://", 1)
return &DockerClient{
baseURL: strings.TrimRight(base, "/") + "/v1.43",
http: &http.Client{Timeout: 60 * time.Second},
}, nil
}
// --- Types serialises vers l'API Engine ---
type containerSpec struct {
Image string `json:"Image"`
Env []string `json:"Env,omitempty"`
Labels map[string]string `json:"Labels,omitempty"`
HostConfig hostConfig `json:"HostConfig"`
NetworkingConfig networkingConfig `json:"NetworkingConfig"`
}
type hostConfig struct {
Memory int64 `json:"Memory,omitempty"`
NanoCPUs int64 `json:"NanoCPUs,omitempty"`
PidsLimit int64 `json:"PidsLimit,omitempty"`
Tmpfs map[string]string `json:"Tmpfs,omitempty"`
SecurityOpt []string `json:"SecurityOpt,omitempty"`
RestartPolicy restartPolicy `json:"RestartPolicy"`
}
type restartPolicy struct {
Name string `json:"Name"`
}
type networkingConfig struct {
EndpointsConfig map[string]endpointSettings `json:"EndpointsConfig,omitempty"`
}
type endpointSettings struct {
Aliases []string `json:"Aliases,omitempty"`
}
// runSpec : forme intermediate cote orchestrateur, mappee sur containerSpec
// au moment d'envoyer la requete.
type runSpec struct {
Name string
Image string
Env []string
Labels map[string]string
Memory int64
Tmpfs map[string]string
Net string
Alias string
}
// --- Operations de haut niveau ---
// SpawnTrio cree postgres + brain + core pour une session.
func (d *DockerClient) SpawnTrio(ctx context.Context, sessionID string, cfg *Config) error {
pgName := "demo-" + sessionID + "-postgres"
brainName := "demo-" + sessionID + "-brain"
coreName := "demo-" + sessionID + "-core"
pgPassword := randomHex(16)
brainSecret := randomHex(32)
adminPassword := randomHex(16)
labels := map[string]string{"demo-session": sessionID}
if err := d.runContainer(ctx, runSpec{
Name: pgName,
Image: "postgres:16-alpine",
Env: []string{"POSTGRES_DB=loremind", "POSTGRES_USER=loremind", "POSTGRES_PASSWORD=" + pgPassword},
Labels: copyLabels(labels, "postgres"),
Memory: cfg.PostgresMemoryBytes,
Tmpfs: map[string]string{"/var/lib/postgresql/data": "rw,size=200m"},
Net: cfg.SessionsNetwork,
Alias: pgName,
}); err != nil {
return fmt.Errorf("spawn postgres: %w", err)
}
if err := d.runContainer(ctx, runSpec{
Name: brainName,
Image: cfg.Registry + "/ietm64/brain:" + cfg.Tag,
Env: []string{
"INTERNAL_SHARED_SECRET=" + brainSecret,
// Pas de provider LLM configure en demo : les features IA echoueront
// proprement, la demo sert principalement a explorer l'edition.
"LLM_PROVIDER=ollama",
"OLLAMA_BASE_URL=http://localhost:1",
},
Labels: copyLabels(labels, "brain"),
Memory: cfg.BrainMemoryBytes,
Net: cfg.SessionsNetwork,
Alias: brainName,
}); err != nil {
return fmt.Errorf("spawn brain: %w", err)
}
if err := d.runContainer(ctx, runSpec{
Name: coreName,
Image: cfg.Registry + "/ietm64/core:" + cfg.Tag,
Env: []string{
"SPRING_DATASOURCE_URL=jdbc:postgresql://" + pgName + ":5432/loremind",
"SPRING_DATASOURCE_USERNAME=loremind",
"SPRING_DATASOURCE_PASSWORD=" + pgPassword,
"BRAIN_BASE_URL=http://" + brainName + ":8000",
"BRAIN_INTERNAL_SECRET=" + brainSecret,
"ADMIN_USERNAME=admin",
"ADMIN_PASSWORD=" + adminPassword,
"DEMO_MODE=true",
// 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,
Net: cfg.SessionsNetwork,
Alias: coreName,
}); err != nil {
return fmt.Errorf("spawn core: %w", err)
}
return nil
}
func (d *DockerClient) runContainer(ctx context.Context, s runSpec) error {
// Pull best-effort : si l'image est deja locale, ContainerCreate la reprendra.
_ = d.pullImage(ctx, s.Image)
spec := containerSpec{
Image: s.Image,
Env: s.Env,
Labels: s.Labels,
HostConfig: hostConfig{
Memory: s.Memory,
NanoCPUs: 1_000_000_000, // 1 vCPU par conteneur
PidsLimit: 200, // anti fork-bomb
Tmpfs: s.Tmpfs,
SecurityOpt: []string{"no-new-privileges:true"},
RestartPolicy: restartPolicy{Name: "no"},
},
NetworkingConfig: networkingConfig{
EndpointsConfig: map[string]endpointSettings{
s.Net: {Aliases: []string{s.Alias}},
},
},
}
body, err := json.Marshal(spec)
if err != nil {
return err
}
createResp, err := d.do(ctx, "POST", "/containers/create?name="+url.QueryEscape(s.Name), body)
if err != nil {
return fmt.Errorf("create %s: %w", s.Name, err)
}
var created struct {
ID string `json:"Id"`
}
if err := json.Unmarshal(createResp, &created); err != nil {
return fmt.Errorf("parse create %s: %w", s.Name, err)
}
if _, err := d.do(ctx, "POST", "/containers/"+created.ID+"/start", nil); err != nil {
return fmt.Errorf("start %s: %w", s.Name, err)
}
return nil
}
// pullImage drain le flux de progression. Erreur silencieuse : si le pull
// echoue (registre prive sans auth, image deja locale), runContainer aura un
// retour clair via ContainerCreate.
func (d *DockerClient) pullImage(ctx context.Context, img string) error {
req, err := http.NewRequestWithContext(ctx, "POST",
d.baseURL+"/images/create?fromImage="+url.QueryEscape(img), nil)
if err != nil {
return err
}
resp, err := d.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode >= 400 {
return fmt.Errorf("pull %s: status %d", img, resp.StatusCode)
}
return nil
}
// WaitReady poll l'endpoint /api/config du core jusqu'a 200 ou timeout.
func (d *DockerClient) WaitReady(ctx context.Context, sessionID string, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
target := "http://demo-" + sessionID + "-core:8080/api/config"
c := &http.Client{Timeout: 2 * time.Second}
for time.Now().Before(deadline) {
resp, err := c.Get(target)
if err == nil {
resp.Body.Close()
if resp.StatusCode == 200 {
return true
}
}
select {
case <-ctx.Done():
return false
case <-time.After(2 * time.Second):
}
}
return false
}
// KillTrio supprime tous les conteneurs labellises demo-session=<id>.
func (d *DockerClient) KillTrio(ctx context.Context, sessionID string) error {
containers, err := d.listContainersWithLabel(ctx, "demo-session="+sessionID)
if err != nil {
return err
}
for _, c := range containers {
_, _ = d.do(ctx, "DELETE", "/containers/"+c.ID+"?force=true", nil)
}
return nil
}
// ListSessionIDs : utilise au boot pour retrouver les conteneurs orphelins.
func (d *DockerClient) ListSessionIDs(ctx context.Context) ([]string, error) {
containers, err := d.listContainersWithLabel(ctx, "demo-session")
if err != nil {
return nil, err
}
seen := map[string]bool{}
for _, c := range containers {
if v, ok := c.Labels["demo-session"]; ok && v != "" {
seen[v] = true
}
}
out := make([]string, 0, len(seen))
for id := range seen {
out = append(out, id)
}
return out, nil
}
type containerInfo struct {
ID string `json:"Id"`
Labels map[string]string `json:"Labels"`
}
func (d *DockerClient) listContainersWithLabel(ctx context.Context, label string) ([]containerInfo, error) {
filters := map[string][]string{"label": {label}}
filtersJSON, _ := json.Marshal(filters)
q := url.Values{}
q.Set("all", "true")
q.Set("filters", string(filtersJSON))
body, err := d.do(ctx, "GET", "/containers/json?"+q.Encode(), nil)
if err != nil {
return nil, err
}
var list []containerInfo
if err := json.Unmarshal(body, &list); err != nil {
return nil, err
}
return list, nil
}
// do envoie une requete et renvoie le body. Une reponse 4xx/5xx est convertie
// en erreur avec le contenu pour faciliter le debug.
func (d *DockerClient) do(ctx context.Context, method, path string, body []byte) ([]byte, error) {
var rdr io.Reader
if body != nil {
rdr = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, d.baseURL+path, rdr)
if err != nil {
return nil, err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := d.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("%s %s: HTTP %d %s", method, path, resp.StatusCode, out)
}
return out, nil
}
// --- helpers ---
func copyLabels(base map[string]string, role string) map[string]string {
out := make(map[string]string, len(base)+1)
for k, v := range base {
out[k] = v
}
out["demo-role"] = role
return out
}
func randomHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}

8
demo/orchestrator/go.mod Normal file
View File

@@ -0,0 +1,8 @@
module github.com/loremind/demo-orchestrator
go 1.23
// Aucune dependance externe : on parle a Docker Engine en HTTP brut
// (cf. docker.go) plutot que d'utiliser github.com/docker/docker, dont le
// graphe transitif est instable d'une version a l'autre (sockets.DialPipe,
// errors.As/Is, otelhttp...).

231
demo/orchestrator/main.go Normal file
View File

@@ -0,0 +1,231 @@
package main
import (
"context"
"encoding/json"
"errors"
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
const cookieName = "loremind-demo-session"
func main() {
cfg := loadConfig()
docker, err := newDockerClient()
if err != nil {
log.Fatalf("docker init: %v", err)
}
mgr := newManager(docker, cfg)
limiter := newRateLimiter(cfg.RateLimitWindow)
// Nettoyage des sessions residuelles au boot (redemarrage orchestrateur).
cleanCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
mgr.CleanupOrphans(cleanCtx)
cancel()
go mgr.RunGC(context.Background())
mux := http.NewServeMux()
mux.HandleFunc("/_demo/ready", readyHandler(mgr))
mux.HandleFunc("/api/", apiHandler(mgr, cfg))
mux.HandleFunc("/", rootHandler(mgr, limiter, cfg))
srv := &http.Server{
Addr: ":80",
Handler: mux,
// Timeouts anti-slowloris. WriteTimeout laisse de la marge pour le
// streaming SSE (ai/chat/stream) qui peut durer plusieurs minutes.
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 60 * time.Second,
WriteTimeout: 10 * time.Minute,
IdleTimeout: 120 * time.Second,
// Headers max : 1 Mo (defaut Go), suffisant.
}
log.Printf("orchestrator listening on :80 (max sessions=%d, ttl=%s, rate window=%s)",
cfg.MaxSessions, cfg.SessionTTL, cfg.RateLimitWindow)
if err := srv.ListenAndServe(); err != nil {
log.Fatalf("http server: %v", err)
}
}
// rootHandler gere toutes les routes non-API : sert l'Angular statique si le
// visiteur a deja une session prete, sinon cree une session (sous rate limit)
// et renvoie la page de preparation.
func rootHandler(mgr *Manager, limiter *rateLimiter, cfg *Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess := currentSession(r, mgr)
// Visiteur connu et session prete -> sert l'app normalement.
if sess != nil && sess.Status == StatusReady {
serveStatic(w, r, cfg.StaticDir)
return
}
// On ne spawn qu'a la navigation initiale (GET d'un document HTML).
// Les assets secondaires (JS/CSS/favicon) ne doivent pas declencher
// de nouvelle session.
if r.Method != http.MethodGet {
http.Error(w, "No session", http.StatusUnauthorized)
return
}
if !acceptsHTML(r) {
http.Error(w, "No session", http.StatusUnauthorized)
return
}
// Session inexistante (ou expiree) -> en creer une, sous rate limit.
if sess == nil {
ip := clientIP(r)
if !limiter.Allow(ip) {
http.Error(w, "Trop de tentatives. Merci d'attendre "+
strconv.Itoa(int(cfg.RateLimitWindow.Seconds()))+"s.",
http.StatusTooManyRequests)
return
}
newSess, err := mgr.Create(r.Context())
if err != nil {
if errors.Is(err, ErrCapacity) {
http.Error(w, "La demo est pleine (max "+
strconv.Itoa(cfg.MaxSessions)+
" sessions simultanees). Merci de reessayer plus tard.",
http.StatusServiceUnavailable)
return
}
http.Error(w, "Impossible de creer la session : "+err.Error(),
http.StatusInternalServerError)
return
}
sess = newSess
setCookie(w, sess.ID, cfg.SessionTTL)
}
servePreparingPage(w, cfg.PreparingPage)
}
}
// apiHandler proxifie /api/* vers le core de la session.
// Bride la taille des bodies a MaxBodyBytes pour limiter les DoS memoire.
func apiHandler(mgr *Manager, cfg *Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess := currentSession(r, mgr)
if sess == nil {
http.Error(w, "No session", http.StatusUnauthorized)
return
}
if sess.Status != StatusReady {
http.Error(w, "Session not ready", http.StatusServiceUnavailable)
return
}
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxBodyBytes)
}
proxy := sessionProxy(sess)
proxy.ServeHTTP(w, r)
}
}
// sessionProxy renvoie (et cree si besoin) un reverse proxy cache dans la
// session via sync.Once : garantit une seule creation meme sous requetes
// concurrentes, sans mutex explicite.
func sessionProxy(sess *Session) *httputil.ReverseProxy {
sess.proxyOnce.Do(func() {
target, _ := url.Parse("http://" + sess.CoreHost + ":8080")
p := httputil.NewSingleHostReverseProxy(target)
p.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
log.Printf("proxy error session=%s: %v", sess.ID, err)
http.Error(w, "Upstream error", http.StatusBadGateway)
}
sess.proxy = p
})
return sess.proxy.(*httputil.ReverseProxy)
}
// readyHandler renvoie l'etat de la session en JSON pour le polling client.
// N'expose aucun ID de session ni d'information sur les autres sessions.
func readyHandler(mgr *Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess := currentSession(r, mgr)
w.Header().Set("Content-Type", "application/json")
if sess == nil {
json.NewEncoder(w).Encode(map[string]any{"status": "none"})
return
}
json.NewEncoder(w).Encode(map[string]any{
"status": string(sess.Status),
"error": sess.Err,
})
}
}
// currentSession lit le cookie et retrouve la session en memoire.
// Si le cookie pointe vers une session disparue (redemarrage orchestrateur ou
// TTL expire), retourne nil -> le handler traitera comme un nouveau visiteur.
func currentSession(r *http.Request, mgr *Manager) *Session {
c, err := r.Cookie(cookieName)
if err != nil || c.Value == "" {
return nil
}
return mgr.Get(c.Value)
}
func setCookie(w http.ResponseWriter, id string, ttl time.Duration) {
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: id,
Path: "/",
HttpOnly: true,
Secure: true, // Traefik termine le TLS ; le browser ne doit envoyer ce cookie qu'en HTTPS.
SameSite: http.SameSiteLaxMode,
MaxAge: int(ttl.Seconds()),
})
}
// serveStatic sert les fichiers de l'Angular build avec fallback sur index.html
// pour que le routeur cote client fonctionne (SPA).
// Le check HasPrefix apres Join + Clean empeche les path traversals (..).
func serveStatic(w http.ResponseWriter, r *http.Request, dir string) {
reqPath := r.URL.Path
if reqPath == "/" || reqPath == "" {
reqPath = "/index.html"
}
fullPath := filepath.Join(dir, filepath.Clean(reqPath))
if !strings.HasPrefix(fullPath, dir) {
http.Error(w, "bad path", http.StatusBadRequest)
return
}
if info, err := os.Stat(fullPath); err == nil && !info.IsDir() {
http.ServeFile(w, r, fullPath)
return
}
http.ServeFile(w, r, filepath.Join(dir, "index.html"))
}
// servePreparingPage sert la page de chargement statique. Le cookie vient
// d'etre pose, le JS de la page utilisera sessionId implicitement via le
// cookie pour poller /_demo/ready.
func servePreparingPage(w http.ResponseWriter, path string) {
data, err := os.ReadFile(path)
if err != nil {
http.Error(w, "Preparing page not found", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
func acceptsHTML(r *http.Request) bool {
accept := r.Header.Get("Accept")
return strings.Contains(accept, "text/html") || accept == "" || accept == "*/*"
}

View File

@@ -0,0 +1,127 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LoreMind — Demo en preparation</title>
<style>
:root {
color-scheme: dark;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #1a1625;
color: #e4def5;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
text-align: center;
}
.card {
max-width: 440px;
padding: 2.5rem 2rem;
}
.logo {
font-size: 2rem;
color: #b794f4;
margin-bottom: 0.5rem;
}
.subtitle {
letter-spacing: 0.2em;
font-size: 0.75rem;
color: #9880c4;
margin-bottom: 2rem;
}
h1 {
font-size: 1.35rem;
font-weight: 500;
margin: 0 0 1rem;
}
p {
color: #aaa0c5;
line-height: 1.6;
font-size: 0.95rem;
}
.spinner {
width: 36px;
height: 36px;
margin: 1.5rem auto 0;
border: 3px solid rgba(183, 148, 244, 0.2);
border-top-color: #b794f4;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error {
display: none;
margin-top: 1rem;
padding: 0.75rem 1rem;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #fca5a5;
border-radius: 6px;
font-size: 0.85rem;
}
.error.visible { display: block; }
</style>
</head>
<body>
<main class="card">
<div class="logo">✦ LoreMind</div>
<div class="subtitle">THE DIGITAL CODEX</div>
<h1>Preparation de votre demo…</h1>
<p>
Nous initialisons une instance isolee rien que pour vous.
Cela prend generalement 20 a 40 secondes.
</p>
<p style="font-size: 0.8rem; color: #7d6ba0; margin-top: 1rem;">
Votre session sera automatiquement reinitialisee au bout de 20 minutes.
</p>
<div class="spinner"></div>
<div id="err" class="error"></div>
</main>
<script>
(function () {
var errBox = document.getElementById('err');
var attempts = 0;
var maxAttempts = 90; // 90 * 2s = 3 min max
function poll() {
attempts++;
fetch('/_demo/ready', { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.status === 'ready') {
window.location.href = '/';
return;
}
if (data.status === 'failed') {
errBox.textContent = 'Echec du demarrage : ' + (data.error || 'raison inconnue') + '. Rechargez la page pour reessayer.';
errBox.classList.add('visible');
return;
}
if (attempts >= maxAttempts) {
errBox.textContent = 'Timeout. Rechargez la page pour reessayer.';
errBox.classList.add('visible');
return;
}
setTimeout(poll, 2000);
})
.catch(function () {
if (attempts >= maxAttempts) {
errBox.textContent = 'Connexion perdue. Rechargez la page pour reessayer.';
errBox.classList.add('visible');
return;
}
setTimeout(poll, 2000);
});
}
poll();
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,79 @@
package main
import (
"net"
"net/http"
"strings"
"sync"
"time"
)
// rateLimiter autorise au plus une action par IP dans une fenetre glissante.
// Pas de token bucket : pour un endpoint de creation de session, "1 par
// fenetre" est largement suffisant et plus simple a raisonner.
type rateLimiter struct {
mu sync.Mutex
lastSeen map[string]time.Time
window time.Duration
}
func newRateLimiter(window time.Duration) *rateLimiter {
rl := &rateLimiter{
lastSeen: make(map[string]time.Time),
window: window,
}
go rl.cleanupLoop()
return rl
}
// Allow renvoie true si l'IP n'a pas deja declenche d'action dans la fenetre.
// Sur true, l'horloge de l'IP est reinitialisee.
func (rl *rateLimiter) Allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
if last, ok := rl.lastSeen[ip]; ok && now.Sub(last) < rl.window {
return false
}
rl.lastSeen[ip] = now
return true
}
// cleanupLoop purge les entrees plus anciennes que 2x la fenetre pour eviter
// la croissance non bornee de la map sous trafic varie.
func (rl *rateLimiter) cleanupLoop() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
cutoff := time.Now().Add(-2 * rl.window)
rl.mu.Lock()
for ip, t := range rl.lastSeen {
if t.Before(cutoff) {
delete(rl.lastSeen, ip)
}
}
rl.mu.Unlock()
}
}
// 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])
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}

View File

@@ -0,0 +1,177 @@
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"log"
"sync"
"time"
)
// SessionStatus reflete l'etat du cycle de vie d'un trio de session.
type SessionStatus string
const (
StatusStarting SessionStatus = "starting"
StatusReady SessionStatus = "ready"
StatusFailed SessionStatus = "failed"
)
// Session represente une demo isolee pour un visiteur.
// CoreHost est le hostname Docker interne du conteneur core de cette session
// (ex: "demo-abc123-core"), vers lequel l'orchestrateur proxifie les /api/*.
type Session struct {
ID string
CreatedAt time.Time
Status SessionStatus
CoreHost string
Err string
// proxy et proxyOnce : reverse-proxy cache, cree au plus une fois via
// sync.Once (evite la race entre deux requetes concurrentes sur la meme
// session). proxy est typee any pour ne pas contraindre sessions.go a
// importer net/http/httputil.
proxy any
proxyOnce sync.Once
}
// Manager gere le cycle de vie des sessions (creation, acces, cleanup).
// Thread-safe : le mutex protege la map contre les acces concurrents (HTTP
// handlers + goroutine de GC).
type Manager struct {
mu sync.Mutex
sessions map[string]*Session
docker *DockerClient
cfg *Config
}
func newManager(docker *DockerClient, cfg *Config) *Manager {
return &Manager{
sessions: make(map[string]*Session),
docker: docker,
cfg: cfg,
}
}
// ErrCapacity est retournee quand MAX_SESSIONS est atteint.
var ErrCapacity = errors.New("demo at capacity")
// Create reserve un slot et lance le spawn des conteneurs en arriere-plan.
// Retourne immediatement avec Status=starting. L'etat bascule a "ready" quand
// les conteneurs sont up et que core repond a /api/config.
func (m *Manager) Create(ctx context.Context) (*Session, error) {
m.mu.Lock()
if len(m.sessions) >= m.cfg.MaxSessions {
m.mu.Unlock()
return nil, ErrCapacity
}
id := newShortID()
sess := &Session{
ID: id,
CreatedAt: time.Now(),
Status: StatusStarting,
CoreHost: "demo-" + id + "-core",
}
m.sessions[id] = sess
m.mu.Unlock()
// Spawn asynchrone : l'utilisateur voit immediatement la page "preparation".
go func() {
spawnCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if err := m.docker.SpawnTrio(spawnCtx, id, m.cfg); err != nil {
log.Printf("session %s spawn failed: %v", id, err)
m.mu.Lock()
sess.Status = StatusFailed
sess.Err = err.Error()
m.mu.Unlock()
// Nettoyage best-effort des conteneurs partiellement crees.
_ = m.docker.KillTrio(context.Background(), id)
return
}
// Attente que core reponde (sinon proxy retourne 502 aux premieres requetes).
if m.docker.WaitReady(spawnCtx, id, 90*time.Second) {
m.mu.Lock()
sess.Status = StatusReady
m.mu.Unlock()
log.Printf("session %s ready", id)
} else {
log.Printf("session %s never became ready", id)
m.mu.Lock()
sess.Status = StatusFailed
sess.Err = "timeout waiting for core"
m.mu.Unlock()
_ = m.docker.KillTrio(context.Background(), id)
}
}()
return sess, nil
}
// Get renvoie la session associee a un ID, ou nil si elle n'existe plus.
func (m *Manager) Get(id string) *Session {
m.mu.Lock()
defer m.mu.Unlock()
return m.sessions[id]
}
// RunGC boucle toutes les minutes pour supprimer les sessions expirees.
// A lancer en goroutine au demarrage.
func (m *Manager) RunGC(ctx context.Context) {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
m.gcOnce()
}
}
}
func (m *Manager) gcOnce() {
cutoff := time.Now().Add(-m.cfg.SessionTTL)
m.mu.Lock()
var expired []string
for id, s := range m.sessions {
if s.CreatedAt.Before(cutoff) {
expired = append(expired, id)
}
}
for _, id := range expired {
delete(m.sessions, id)
}
m.mu.Unlock()
for _, id := range expired {
log.Printf("session %s expired, killing containers", id)
if err := m.docker.KillTrio(context.Background(), id); err != nil {
log.Printf("kill %s: %v", id, err)
}
}
}
// CleanupOrphans tue les conteneurs demo-* qui ne correspondent a aucune
// session en memoire. Appele au demarrage pour gerer un redemarrage brutal.
func (m *Manager) CleanupOrphans(ctx context.Context) {
ids, err := m.docker.ListSessionIDs(ctx)
if err != nil {
log.Printf("list orphans: %v", err)
return
}
for _, id := range ids {
log.Printf("cleaning orphan session %s", id)
_ = m.docker.KillTrio(ctx, id)
}
}
// newShortID genere un identifiant hexadecimal de 32 caracteres (128 bits).
// 128 bits d'entropie rendent les collisions et le brute-force statistiquement
// impossibles, meme si un attaquant pouvait tenter des millions de cookies.
func newShortID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}

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

View File

@@ -60,7 +60,8 @@
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"buildTarget": "web:build"
"buildTarget": "web:build",
"proxyConfig": "proxy.conf.json"
}
}
}

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,90 @@
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 }) => {
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()}`;
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);
});
});

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.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "loremind-web",
"version": "0.4.0",
"version": "0.6.1",
"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.0",
"version": "0.6.2",
"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'] },
},
],
});

8
web/proxy.conf.json Normal file
View File

@@ -0,0 +1,8 @@
{
"/api": {
"target": "http://localhost:8080",
"secure": false,
"changeOrigin": true,
"logLevel": "warn"
}
}

View File

@@ -1,4 +1,5 @@
import { Routes } from '@angular/router';
import { hiddenInDemoGuard } from './guards/demo-mode.guard';
export const routes: Routes = [
{ path: 'lore', loadComponent: () => import('./lore/lore.component').then(m => m.LoreComponent) },
@@ -30,6 +31,8 @@ export const routes: Routes = [
{ path: 'game-systems', loadComponent: () => import('./game-systems/game-systems.component').then(m => m.GameSystemsComponent) },
{ path: 'game-systems/create', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) },
{ path: 'game-systems/:id/edit', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) },
{ path: 'settings', loadComponent: () => import('./settings/settings.component').then(m => m.SettingsComponent) },
// Routes masquees en mode demo : ajouter canActivate: [hiddenInDemoGuard]
// (a prevoir aussi sur la future route d'export VTT).
{ path: 'settings', canActivate: [hiddenInDemoGuard], loadComponent: () => import('./settings/settings.component').then(m => m.SettingsComponent) },
{ path: '', redirectTo: '/lore', pathMatch: 'full' }
];

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,8 +18,9 @@
</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">

View File

@@ -5,6 +5,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule, BookOpen } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
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';
@@ -33,6 +34,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private layoutService: LayoutService
) {
this.form = this.fb.group({
@@ -50,7 +52,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
}).subscribe(({ campaign, allCampaigns, treeData }) => {
this.existingArcCount = treeData.arcs.length;

View File

@@ -43,8 +43,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,8 +54,9 @@
</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">
@@ -63,16 +65,18 @@
<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 +85,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 +96,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 +106,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">

View File

@@ -6,6 +6,7 @@ import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { PageService } from '../../services/page.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
@@ -68,6 +69,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
@@ -105,7 +107,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
arc: this.campaignService.getArcById(this.arcId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
}).pipe(
switchMap(data => {
const lid = data.campaign.loreId ?? null;

View File

@@ -5,6 +5,7 @@ import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { PageService } from '../../services/page.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
@@ -42,6 +43,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
@@ -64,7 +66,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
arc: this.campaignService.getArcById(this.arcId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
}).pipe(
switchMap(data => {
const lid = data.campaign.loreId ?? null;

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

@@ -70,7 +70,7 @@
</div>
</div>
<section class="detail-section characters-section">
<section class="detail-section characters-section" *ngIf="!editing">
<div class="section-header">
<h2>Personnages joueurs</h2>
<button class="btn-add" (click)="createCharacter()">
@@ -99,7 +99,7 @@
</div>
</section>
<section class="detail-section arcs-section">
<section class="detail-section arcs-section" *ngIf="!editing">
<div class="section-header">
<h2>Arcs narratifs</h2>
<button class="btn-add" (click)="createArc()">

View File

@@ -77,8 +77,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
switchMap(id => forkJoin({
campaign: this.campaignService.getCampaignById(id),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, id).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {} } as CampaignTreeData))
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [] } as CampaignTreeData))
)
}))
).subscribe(({ campaign, allCampaigns, treeData }) => {
@@ -111,8 +111,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
forkJoin({
campaign: this.campaignService.getCampaignById(id),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, id).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {} } as CampaignTreeData))
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [] } as CampaignTreeData))
)
}).subscribe(({ campaign, allCampaigns, treeData }) => {
this.campaign = campaign;

View File

@@ -1,8 +1,10 @@
import { Observable, forkJoin, of } from 'rxjs';
import { switchMap, map } from 'rxjs/operators';
import { CampaignService } from '../services/campaign.service';
import { CharacterService } from '../services/character.service';
import { TreeItem } from '../services/layout.service';
import { Arc, Chapter, Scene } from '../services/campaign.model';
import { Character } from '../services/character.model';
/**
* Helper — charge l'arborescence complète d'une campagne (arcs -> chapitres -> scènes)
@@ -16,16 +18,21 @@ export interface CampaignTreeData {
arcs: Arc[];
chaptersByArc: Record<string, Chapter[]>;
scenesByChapter: Record<string, Scene[]>;
characters: Character[];
}
export function loadCampaignTreeData(
service: CampaignService,
campaignId: string
campaignId: string,
characterService: CharacterService
): Observable<CampaignTreeData> {
return service.getArcs(campaignId).pipe(
switchMap(arcs => {
return forkJoin({
arcs: service.getArcs(campaignId),
characters: characterService.getByCampaign(campaignId)
}).pipe(
switchMap(({ arcs, characters }) => {
if (arcs.length === 0) {
return of({ arcs, chaptersByArc: {}, scenesByChapter: {} });
return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters });
}
const chapterCalls = arcs.map(a =>
service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters })))
@@ -40,7 +47,7 @@ export function loadCampaignTreeData(
});
if (allChapters.length === 0) {
return of({ arcs, chaptersByArc, scenesByChapter: {} });
return of({ arcs, chaptersByArc, scenesByChapter: {}, characters });
}
const sceneCalls = allChapters.map(c =>
service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes })))
@@ -49,7 +56,7 @@ export function loadCampaignTreeData(
map(sceneResults => {
const scenesByChapter: Record<string, Scene[]> = {};
sceneResults.forEach(r => { scenesByChapter[r.chapterId] = r.scenes; });
return { arcs, chaptersByArc, scenesByChapter };
return { arcs, chaptersByArc, scenesByChapter, characters };
})
);
})
@@ -67,9 +74,33 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
// IDs préfixés par type pour éviter les collisions dans LayoutService.expanded
// (chaque entité a sa propre séquence IDENTITY en base → arc.id=1 et chapter.id=1
// peuvent coexister et se marchaient sur les pieds dans le Set<string> global).
const sortedCharacters = [...data.characters].sort(byName);
const characterItems: TreeItem[] = sortedCharacters.map(ch => ({
id: `character-${ch.id}`,
label: ch.name,
route: `/campaigns/${campaignId}/characters/${ch.id}/edit`
}));
const charactersNode: TreeItem = {
id: 'characters-root',
label: 'Personnages',
iconKey: 'users',
children: characterItems,
meta: characterItems.length ? String(characterItems.length) : undefined,
sectionHeaderBefore: 'Personnages',
// Note : si pas d'arcs, le filet au-dessus de "Personnages" est masqué par CSS
// (:first-child), ce qui est voulu — on ne veut pas de ligne seule en haut.
createActions: [{
id: 'new-character',
label: 'Nouveau PJ',
route: `/campaigns/${campaignId}/characters/create`,
actionIcon: 'plus'
}]
};
const sortedArcs = [...data.arcs].sort(byName);
return sortedArcs.map(arc => {
const arcNodes: TreeItem[] = sortedArcs.map((arc, idx) => {
const sortedChapters = [...(data.chaptersByArc[arc.id!] ?? [])].sort(byName);
const chapterItems: TreeItem[] = sortedChapters.map(ch => {
@@ -98,6 +129,8 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
label: arc.name,
children: chapterItems,
route: `/campaigns/${campaignId}/arcs/${arc.id}`,
sectionHeaderBefore: idx === 0 ? 'Narration' : undefined,
createActions: [{
id: `new-chapter-${arc.id}`,
label: 'Nouveau chapitre',
@@ -106,4 +139,6 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
}]
};
});
return [...arcNodes, charactersNode];
}

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,8 +19,9 @@
</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">

View File

@@ -5,6 +5,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
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';
@@ -32,6 +33,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private layoutService: LayoutService
) {
this.form = this.fb.group({
@@ -50,7 +52,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
}).subscribe(({ campaign, allCampaigns, treeData }) => {
const currentArc = treeData.arcs.find(a => a.id === this.arcId);
this.arcName = currentArc?.name ?? '';

View File

@@ -43,8 +43,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 +54,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 +64,9 @@
</div>
<div class="field">
<label>Notes du Maître de Jeu</label>
<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 +76,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">

View File

@@ -6,6 +6,7 @@ import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { PageService } from '../../services/page.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
@@ -61,6 +62,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
@@ -98,7 +100,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
chapter: this.campaignService.getChapterById(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
}).pipe(
switchMap(data => {
const lid = data.campaign.loreId ?? null;

View File

@@ -4,6 +4,7 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule, ArrowLeft } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { Campaign, Chapter, Scene } from '../../services/campaign.model';
@@ -48,6 +49,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
) {}
@@ -67,7 +69,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
allCampaigns: this.campaignService.getAllCampaigns(),
chapter: this.campaignService.getChapterById(this.chapterId),
scenes: this.campaignService.getScenes(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
}).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => {
this.chapter = chapter;
this.scenes = scenes;

View File

@@ -5,6 +5,7 @@ import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Pencil, Network, Trash2 } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { PageService } from '../../services/page.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
@@ -41,6 +42,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
@@ -67,7 +69,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
chapter: this.campaignService.getChapterById(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
}).pipe(
switchMap(data => {
const lid = data.campaign.loreId ?? null;

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,8 +19,9 @@
</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">

View File

@@ -5,6 +5,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
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';
@@ -33,6 +34,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private layoutService: LayoutService
) {
this.form = this.fb.group({
@@ -52,7 +54,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
}).subscribe(({ campaign, allCampaigns, treeData }) => {
const currentChapter = (treeData.chaptersByArc[this.arcId] ?? []).find(c => c.id === this.chapterId);
this.chapterName = currentChapter?.name ?? '';

View File

@@ -43,8 +43,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,8 +54,9 @@
</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">
@@ -65,17 +67,18 @@
<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 +182,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">

View File

@@ -6,6 +6,7 @@ import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { PageService } from '../../services/page.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
@@ -65,6 +66,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
@@ -116,7 +118,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
allCampaigns: this.campaignService.getAllCampaigns(),
scene: this.campaignService.getSceneById(this.sceneId),
chapterScenes: this.campaignService.getScenes(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
}).pipe(
switchMap(data => {
const lid = data.campaign.loreId ?? null;

View File

@@ -5,6 +5,7 @@ import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { PageService } from '../../services/page.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
@@ -41,6 +42,7 @@ export class SceneViewComponent implements OnInit, OnDestroy {
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
@@ -70,7 +72,7 @@ export class SceneViewComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
scene: this.campaignService.getSceneById(this.sceneId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
}).pipe(
switchMap(data => {
const lid = data.campaign.loreId ?? null;

View File

@@ -0,0 +1,17 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { ConfigService } from '../services/config.service';
/**
* Bloque l'acces aux routes sensibles quand demoMode est actif et redirige
* vers la home. Defense UX ; le verrou serveur reste la source de verite.
*/
export const hiddenInDemoGuard: CanActivateFn = () => {
const config = inject(ConfigService);
const router = inject(Router);
if (config.demoMode) {
router.navigate(['/']);
return false;
}
return true;
};

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()">
@@ -39,7 +39,7 @@
</div>
<!-- ============ Grille des dossiers racine ============ -->
<section class="detail-section nodes-section">
<section class="detail-section nodes-section" *ngIf="!editing">
<div class="section-header">
<h2>Dossiers</h2>
<button class="btn-add" (click)="navigateToCreateNode()">

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

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

View File

@@ -45,8 +45,8 @@ export type NarrativeEntityType = 'arc' | 'chapter' | 'scene' | 'character';
@Injectable({ providedIn: 'root' })
export class AiChatService {
private readonly loreEndpoint = 'http://localhost:8080/api/ai/chat/stream';
private readonly campaignEndpoint = 'http://localhost:8080/api/ai/chat/stream-campaign';
private readonly loreEndpoint = '/api/ai/chat/stream';
private readonly campaignEndpoint = '/api/ai/chat/stream-campaign';
/**
* Streame la réponse de l'IA pour un historique de messages donné (chat ancré Lore).

View File

@@ -30,7 +30,7 @@ export interface ChapterDeletionImpact {
providedIn: 'root'
})
export class CampaignService {
private apiUrl = 'http://localhost:8080/api/campaigns';
private apiUrl = '/api/campaigns';
constructor(private http: HttpClient) {}
@@ -60,73 +60,76 @@ export class CampaignService {
// ========== ARC ==========
getArcs(campaignId: string): Observable<Arc[]> {
return this.http.get<Arc[]>(`http://localhost:8080/api/arcs/campaign/${campaignId}`);
const params = new HttpParams().set('campaignId', campaignId);
return this.http.get<Arc[]>('/api/arcs', { params });
}
getArcById(id: string): Observable<Arc> {
return this.http.get<Arc>(`http://localhost:8080/api/arcs/${id}`);
return this.http.get<Arc>(`/api/arcs/${id}`);
}
createArc(payload: ArcCreate): Observable<Arc> {
return this.http.post<Arc>('http://localhost:8080/api/arcs', payload);
return this.http.post<Arc>('/api/arcs', payload);
}
updateArc(id: string, payload: ArcCreate): Observable<Arc> {
return this.http.put<Arc>(`http://localhost:8080/api/arcs/${id}`, payload);
return this.http.put<Arc>(`/api/arcs/${id}`, payload);
}
deleteArc(id: string): Observable<void> {
return this.http.delete<void>(`http://localhost:8080/api/arcs/${id}`);
return this.http.delete<void>(`/api/arcs/${id}`);
}
getArcDeletionImpact(id: string): Observable<ArcDeletionImpact> {
return this.http.get<ArcDeletionImpact>(`http://localhost:8080/api/arcs/${id}/deletion-impact`);
return this.http.get<ArcDeletionImpact>(`/api/arcs/${id}/deletion-impact`);
}
// ========== CHAPTER ==========
getChapters(arcId: string): Observable<Chapter[]> {
return this.http.get<Chapter[]>(`http://localhost:8080/api/chapters/arc/${arcId}`);
const params = new HttpParams().set('arcId', arcId);
return this.http.get<Chapter[]>('/api/chapters', { params });
}
getChapterById(id: string): Observable<Chapter> {
return this.http.get<Chapter>(`http://localhost:8080/api/chapters/${id}`);
return this.http.get<Chapter>(`/api/chapters/${id}`);
}
createChapter(payload: ChapterCreate): Observable<Chapter> {
return this.http.post<Chapter>('http://localhost:8080/api/chapters', payload);
return this.http.post<Chapter>('/api/chapters', payload);
}
updateChapter(id: string, payload: ChapterCreate): Observable<Chapter> {
return this.http.put<Chapter>(`http://localhost:8080/api/chapters/${id}`, payload);
return this.http.put<Chapter>(`/api/chapters/${id}`, payload);
}
deleteChapter(id: string): Observable<void> {
return this.http.delete<void>(`http://localhost:8080/api/chapters/${id}`);
return this.http.delete<void>(`/api/chapters/${id}`);
}
getChapterDeletionImpact(id: string): Observable<ChapterDeletionImpact> {
return this.http.get<ChapterDeletionImpact>(`http://localhost:8080/api/chapters/${id}/deletion-impact`);
return this.http.get<ChapterDeletionImpact>(`/api/chapters/${id}/deletion-impact`);
}
// ========== SCENE ==========
getScenes(chapterId: string): Observable<Scene[]> {
return this.http.get<Scene[]>(`http://localhost:8080/api/scenes/chapter/${chapterId}`);
const params = new HttpParams().set('chapterId', chapterId);
return this.http.get<Scene[]>('/api/scenes', { params });
}
getSceneById(id: string): Observable<Scene> {
return this.http.get<Scene>(`http://localhost:8080/api/scenes/${id}`);
return this.http.get<Scene>(`/api/scenes/${id}`);
}
createScene(payload: SceneCreate): Observable<Scene> {
return this.http.post<Scene>('http://localhost:8080/api/scenes', payload);
return this.http.post<Scene>('/api/scenes', payload);
}
updateScene(id: string, payload: SceneCreate): Observable<Scene> {
return this.http.put<Scene>(`http://localhost:8080/api/scenes/${id}`, payload);
return this.http.put<Scene>(`/api/scenes/${id}`, payload);
}
deleteScene(id: string): Observable<void> {
return this.http.delete<void>(`http://localhost:8080/api/scenes/${id}`);
return this.http.delete<void>(`/api/scenes/${id}`);
}
search(q: string): Observable<Campaign[]> {

View File

@@ -8,7 +8,7 @@ import { Character, CharacterCreate } from './character.model';
*/
@Injectable({ providedIn: 'root' })
export class CharacterService {
private apiUrl = 'http://localhost:8080/api/characters';
private apiUrl = '/api/characters';
constructor(private http: HttpClient) {}

View File

@@ -0,0 +1,31 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
/**
* Configuration publique chargee une seule fois au demarrage via APP_INITIALIZER.
* Le flag demoMode bascule l'UI en mode vitrine (Settings/Export masques).
*/
export interface PublicConfig {
demoMode: boolean;
}
@Injectable({ providedIn: 'root' })
export class ConfigService {
private config: PublicConfig = { demoMode: false };
constructor(private http: HttpClient) {}
async load(): Promise<void> {
try {
this.config = await firstValueFrom(this.http.get<PublicConfig>('/api/config'));
} catch {
// Si l'endpoint n'est pas joignable au boot, on reste sur le default
// (demoMode=false) pour ne pas bloquer l'app en dev.
}
}
get demoMode(): boolean {
return this.config.demoMode;
}
}

View File

@@ -17,7 +17,7 @@ import {
*/
@Injectable({ providedIn: 'root' })
export class ConversationService {
private readonly apiUrl = 'http://localhost:8080/api/conversations';
private readonly apiUrl = '/api/conversations';
constructor(private http: HttpClient) {}

View File

@@ -8,7 +8,7 @@ import { GameSystem, GameSystemCreate } from './game-system.model';
*/
@Injectable({ providedIn: 'root' })
export class GameSystemService {
private apiUrl = 'http://localhost:8080/api/game-systems';
private apiUrl = '/api/game-systems';
constructor(private http: HttpClient) {}

View File

@@ -9,8 +9,8 @@ import { Image } from './image.model';
*/
@Injectable({ providedIn: 'root' })
export class ImageService {
/** Base absolue du backend — utile pour construire des URLs complètes (<img src>). */
readonly apiBase = 'http://localhost:8080';
/** Base du backend (vide = même origine que le front, résolue par le navigateur). */
readonly apiBase = '';
private apiUrl = `${this.apiBase}/api/images`;
constructor(private http: HttpClient) {}

View File

@@ -11,6 +11,11 @@ export interface TreeItem {
iconKey?: string;
/** Petit badge affiché à droite (ex: "3" pour compter les pages d'un dossier). */
meta?: string;
/**
* Libellé de section affiché AU-DESSUS du nœud, avec un filet de séparation.
* Utilisé pour grouper visuellement des nœuds racines (ex: "Personnages" vs "Narration").
*/
sectionHeaderBefore?: string;
/**
* Actions de creation contextuelles (ex: "+ Nouveau chapitre" sur un arc).
* Affichees comme boutons icone au survol du noeud (repli visuel), et en

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,34 +24,64 @@ 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'
})
export class LoreService {
private apiUrl = 'http://localhost:8080/api/lores';
private nodesUrl = 'http://localhost:8080/api/lore-nodes';
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 = 'http://localhost:8080/api/pages';
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

@@ -36,7 +36,7 @@ export interface OllamaModelInfo {
@Injectable({ providedIn: 'root' })
export class SettingsService {
private readonly apiUrl = 'http://localhost:8080/api/settings';
private readonly apiUrl = '/api/settings';
// HTTP Basic : le browser gere le prompt natif de credentials au premier 401.
// withCredentials=true pour que les creds soient renvoyees sur les appels

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 = 'http://localhost:8080/api/templates';
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

@@ -29,6 +29,9 @@
<!-- Template récursif : un noeud d'arbre rend son bouton, puis ses enfants via ce même template -->
<ng-template #treeNode let-item let-level="level">
<div class="tree-section-header" *ngIf="level === 0 && item.sectionHeaderBefore">
{{ item.sectionHeaderBefore }}
</div>
<div class="tree-item" [style.padding-left.px]="level * 12">
<div class="tree-row">
<button

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.
@@ -110,6 +110,23 @@
padding: 0.25rem 0;
}
// En-tête de section — groupe visuellement les nœuds racines (ex: Personnages / Narration).
// Un filet au-dessus crée la séparation ; pas de filet pour la première section
// (le titre suffit) — on cible ça via :not(:first-child).
.tree-section-header {
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #9ca3af;
padding: 0.6rem 0.5rem 0.3rem;
}
.tree > .tree-section-header:not(:first-child) {
border-top: 1px solid #374151;
margin-top: 0.35rem;
padding-top: 0.6rem;
}
.tree-btn {
display: flex;
align-items: center;

View File

@@ -53,11 +53,11 @@
<lucide-icon [img]="Dices" [size]="16"></lucide-icon>
<span>Systèmes de JDR</span>
</button>
<button class="tool-btn">
<button class="tool-btn" *ngIf="!config.demoMode">
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>Export VTT</span>
</button>
<button class="tool-btn" [class.active]="currentRoute.startsWith('/settings')" (click)="navigateTo('/settings')">
<button class="tool-btn" *ngIf="!config.demoMode" [class.active]="currentRoute.startsWith('/settings')" (click)="navigateTo('/settings')">
<lucide-icon [img]="Settings" [size]="16"></lucide-icon>
<span>Paramètres</span>
</button>

View File

@@ -4,6 +4,7 @@ import { Router } from '@angular/router';
import { LucideAngularModule, Search, Download, Settings, ArrowLeft, Dices } from 'lucide-angular';
import { LayoutService } from '../services/layout.service';
import { GlobalSearchService } from '../services/global-search.service';
import { ConfigService } from '../services/config.service';
// Single source of truth pour la version affichée dans le footer :
// on lit directement package.json à la compilation (resolveJsonModule).
import packageJson from '../../../package.json';
@@ -30,7 +31,8 @@ export class SidebarComponent {
constructor(
private router: Router,
private layoutService: LayoutService,
private globalSearch: GlobalSearchService
private globalSearch: GlobalSearchService,
public config: ConfigService
) {
this.router.events.subscribe(() => {
this.currentRoute = this.router.url;

View File

@@ -3,6 +3,8 @@ import { AppComponent } from './app/app.component';
import { PreloadAllModules, provideRouter, withPreloading } from '@angular/router';
import { routes } from './app/app.routes';
import { provideHttpClient } from '@angular/common/http';
import { APP_INITIALIZER } from '@angular/core';
import { ConfigService } from './app/services/config.service';
// withPreloading(PreloadAllModules) : une fois l'app initiale rendue, Angular
// telecharge en arriere-plan tous les chunks lazy-loades. Consequence : la
@@ -13,5 +15,11 @@ bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes, withPreloading(PreloadAllModules)),
provideHttpClient(),
{
provide: APP_INITIALIZER,
useFactory: (config: ConfigService) => () => config.load(),
deps: [ConfigService],
multi: true,
},
],
}).catch((err: Error) => console.error(err));