Compare commits
9 Commits
7c74c12f3e
...
v0.9.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
| 694f687fec | |||
| 87865338a0 | |||
| 586ddceff6 | |||
| 4b9b7f0995 | |||
| 3d73b1e6a7 | |||
| 759e47fc1f | |||
| f71bf3fcad | |||
| 0cd99dfb32 | |||
| f24ef0891e |
@@ -42,19 +42,24 @@ jobs:
|
|||||||
username: ${{ env.GHCR_NAMESPACE }}
|
username: ${{ env.GHCR_NAMESPACE }}
|
||||||
password: ${{ secrets.GHCR_TOKEN }}
|
password: ${{ secrets.GHCR_TOKEN }}
|
||||||
|
|
||||||
- name: Extract version
|
# Detection du canal :
|
||||||
|
# - tag vX.Y.Z -> stable (push :latest + :version sur les repos publics)
|
||||||
|
# - tag vX.Y.Z-beta* -> beta (push :beta + :version sur les repos GHCR prives
|
||||||
|
# loremind-beta-<component> ; backup Gitea avec :version)
|
||||||
|
- name: Extract version & channel
|
||||||
id: meta
|
id: meta
|
||||||
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
run: |
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||||
|
if [[ "${VERSION}" == *-beta* ]]; then
|
||||||
|
echo "channel=beta" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "channel=stable" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
# Push vers les deux registries en un seul build (build-push-action
|
# Build & push canal STABLE
|
||||||
# accepte une liste de tags ; aucun build supplementaire necessaire).
|
- name: Build & push ${{ matrix.component }} (stable)
|
||||||
# Naming :
|
if: steps.meta.outputs.channel == 'stable'
|
||||||
# - Gitea : conserve l'ancien pattern ietm64/<component> pour ne pas
|
|
||||||
# casser les installs existantes qui ont REGISTRY=git.igmlcreation.fr
|
|
||||||
# dans leur .env.
|
|
||||||
# - GHCR : nouveau pattern igmlcreation/loremind-<component> qui evite
|
|
||||||
# la collision avec d'autres projets de l'org.
|
|
||||||
- name: Build & push ${{ matrix.component }}
|
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: ./${{ matrix.component }}
|
context: ./${{ matrix.component }}
|
||||||
@@ -64,3 +69,73 @@ jobs:
|
|||||||
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||||
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:latest
|
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:latest
|
||||||
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||||
|
|
||||||
|
# Build & push canal BETA
|
||||||
|
# GHCR : repos prives loremind-beta-<component> (gated par PAT distribue
|
||||||
|
# via le relais Patreon aux tiers Compagnon).
|
||||||
|
# Gitea : backup prive avec :version uniquement (pas de :latest pour ne
|
||||||
|
# pas faire upgrader les installs branchees sur Gitea).
|
||||||
|
- name: Build & push ${{ matrix.component }} (beta)
|
||||||
|
if: steps.meta.outputs.channel == 'beta'
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./${{ matrix.component }}
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||||
|
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-beta-${{ matrix.component }}:beta
|
||||||
|
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-beta-${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||||
|
|
||||||
|
# Job separe pour le sidecar `switcher`.
|
||||||
|
# Pourquoi separe : le switcher est volontairement HORS de IMAGE_NAMESPACE
|
||||||
|
# (cf. docker-compose.yml). Il est toujours pulle depuis le repo public
|
||||||
|
# `loremind-switcher`, quel que soit le canal de l'instance. On le build
|
||||||
|
# donc uniquement sur les releases stables — pas la peine de re-publier
|
||||||
|
# une variante beta du switcher, c'est une infrastructure neutre.
|
||||||
|
build-switcher:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Detect channel
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||||
|
if [[ "${VERSION}" == *-beta* ]]; then
|
||||||
|
echo "channel=beta" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "channel=stable" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Login to Gitea Registry
|
||||||
|
if: steps.meta.outputs.channel == 'stable'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.GITEA_REGISTRY }}
|
||||||
|
username: ${{ env.GITEA_REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.DOCKER_PAT }}
|
||||||
|
|
||||||
|
- name: Login to GHCR
|
||||||
|
if: steps.meta.outputs.channel == 'stable'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.GHCR_REGISTRY }}
|
||||||
|
username: ${{ env.GHCR_NAMESPACE }}
|
||||||
|
password: ${{ secrets.GHCR_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build & push switcher (stable only)
|
||||||
|
if: steps.meta.outputs.channel == 'stable'
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./switcher
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/switcher:latest
|
||||||
|
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/switcher:${{ steps.meta.outputs.version }}
|
||||||
|
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-switcher:latest
|
||||||
|
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-switcher:${{ steps.meta.outputs.version }}
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -107,3 +107,4 @@ docker-compose.override.yml
|
|||||||
# Relais OAuth Patreon (repo Gitea separe, clone localement pour facilite)
|
# Relais OAuth Patreon (repo Gitea separe, clone localement pour facilite)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
relay/
|
relay/
|
||||||
|
scripts/bump-version.mjs
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from app.domain.models import (
|
|||||||
NarrativeEntityContext,
|
NarrativeEntityContext,
|
||||||
PageContext,
|
PageContext,
|
||||||
PageSummary,
|
PageSummary,
|
||||||
|
SessionContext,
|
||||||
)
|
)
|
||||||
from app.domain.ports import LLMChatProvider
|
from app.domain.ports import LLMChatProvider
|
||||||
|
|
||||||
@@ -67,6 +68,7 @@ class ChatUseCase:
|
|||||||
campaign_context: CampaignStructuralContext | None = None,
|
campaign_context: CampaignStructuralContext | None = None,
|
||||||
narrative_entity: NarrativeEntityContext | None = None,
|
narrative_entity: NarrativeEntityContext | None = None,
|
||||||
game_system_context: GameSystemContext | None = None,
|
game_system_context: GameSystemContext | None = None,
|
||||||
|
session_context: SessionContext | None = None,
|
||||||
) -> AsyncIterator[str]:
|
) -> AsyncIterator[str]:
|
||||||
"""Streame les tokens de la réponse assistant pour le dernier message user.
|
"""Streame les tokens de la réponse assistant pour le dernier message user.
|
||||||
|
|
||||||
@@ -76,7 +78,7 @@ class ChatUseCase:
|
|||||||
cette règle à la frontière HTTP.
|
cette règle à la frontière HTTP.
|
||||||
"""
|
"""
|
||||||
system_prompt = self._build_system_prompt(
|
system_prompt = self._build_system_prompt(
|
||||||
lore_context, page_context, campaign_context, narrative_entity, game_system_context
|
lore_context, page_context, campaign_context, narrative_entity, game_system_context, session_context
|
||||||
)
|
)
|
||||||
async for token in self._llm.stream_chat(
|
async for token in self._llm.stream_chat(
|
||||||
messages,
|
messages,
|
||||||
@@ -92,12 +94,13 @@ class ChatUseCase:
|
|||||||
campaign_context: CampaignStructuralContext | None = None,
|
campaign_context: CampaignStructuralContext | None = None,
|
||||||
narrative_entity: NarrativeEntityContext | None = None,
|
narrative_entity: NarrativeEntityContext | None = None,
|
||||||
game_system_context: GameSystemContext | None = None,
|
game_system_context: GameSystemContext | None = None,
|
||||||
|
session_context: SessionContext | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Version publique — utilisée par le controller HTTP pour compter
|
"""Version publique — utilisée par le controller HTTP pour compter
|
||||||
les tokens du system prompt avant de streamer (jauge de contexte).
|
les tokens du system prompt avant de streamer (jauge de contexte).
|
||||||
"""
|
"""
|
||||||
return self._build_system_prompt(
|
return self._build_system_prompt(
|
||||||
lore_context, page_context, campaign_context, narrative_entity, game_system_context
|
lore_context, page_context, campaign_context, narrative_entity, game_system_context, session_context
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Construction du system prompt --------------------------------------
|
# --- Construction du system prompt --------------------------------------
|
||||||
@@ -109,6 +112,7 @@ class ChatUseCase:
|
|||||||
campaign: CampaignStructuralContext | None,
|
campaign: CampaignStructuralContext | None,
|
||||||
narrative: NarrativeEntityContext | None,
|
narrative: NarrativeEntityContext | None,
|
||||||
game_system: GameSystemContext | None = None,
|
game_system: GameSystemContext | None = None,
|
||||||
|
session: SessionContext | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
sections = [_BASE_SYSTEM]
|
sections = [_BASE_SYSTEM]
|
||||||
if lore is not None:
|
if lore is not None:
|
||||||
@@ -121,6 +125,8 @@ class ChatUseCase:
|
|||||||
sections.append(self._format_page(page))
|
sections.append(self._format_page(page))
|
||||||
if narrative is not None:
|
if narrative is not None:
|
||||||
sections.append(self._format_narrative_entity(narrative))
|
sections.append(self._format_narrative_entity(narrative))
|
||||||
|
if session is not None:
|
||||||
|
sections.append(self._format_session(session))
|
||||||
return "\n\n".join(sections)
|
return "\n\n".join(sections)
|
||||||
|
|
||||||
# --- Blocs Lore ---------------------------------------------------------
|
# --- Blocs Lore ---------------------------------------------------------
|
||||||
@@ -342,6 +348,53 @@ class ChatUseCase:
|
|||||||
f"{sections_block}"
|
f"{sections_block}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- Bloc Session de jeu (Play Context) ---------------------------------
|
||||||
|
|
||||||
|
_ENTRY_TYPE_LABELS = {
|
||||||
|
"NOTE": "Note du MJ",
|
||||||
|
"EVENT": "Évènement",
|
||||||
|
"DICE_ROLL": "Jet de dés",
|
||||||
|
"PLAYER_ACTION": "Action joueur",
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_session(sc: SessionContext) -> str:
|
||||||
|
"""Bloc journal de la session en cours.
|
||||||
|
|
||||||
|
Fournit à l'IA le contexte temporel : ce qui s'est passé jusqu'ici,
|
||||||
|
dans l'ordre chronologique. Permet de référencer un PNJ rencontré,
|
||||||
|
rappeler un évènement antérieur, ou rebondir sur une action joueur.
|
||||||
|
"""
|
||||||
|
status = "EN COURS" if sc.active else "TERMINÉE"
|
||||||
|
started = f" — démarrée {sc.started_at}" if sc.started_at else ""
|
||||||
|
|
||||||
|
if not sc.entries:
|
||||||
|
entries_block = "(Aucune entrée dans le journal pour l'instant — la session vient de commencer.)"
|
||||||
|
else:
|
||||||
|
lines: list[str] = []
|
||||||
|
for e in sc.entries:
|
||||||
|
label = ChatUseCase._ENTRY_TYPE_LABELS.get(e.type, e.type)
|
||||||
|
ts = f" [{e.occurred_at}]" if e.occurred_at else ""
|
||||||
|
# Indentation sur les contenus multi-lignes pour préserver la lisibilité
|
||||||
|
content = e.content.replace("\n", "\n ")
|
||||||
|
lines.append(f"- {label}{ts} : {content}")
|
||||||
|
entries_block = "\n".join(lines)
|
||||||
|
|
||||||
|
return (
|
||||||
|
"--- SESSION DE JEU EN COURS ---\n"
|
||||||
|
f"Nom : {sc.session_name}\n"
|
||||||
|
f"Statut : {status}{started}\n\n"
|
||||||
|
"Journal chronologique (du plus ancien au plus récent) :\n"
|
||||||
|
f"{entries_block}\n\n"
|
||||||
|
"IMPORTANT : tu es l'assistant du MJ PENDANT la partie. Tes réponses doivent :\n"
|
||||||
|
"- Tenir compte des évènements déjà capturés dans le journal ci-dessus.\n"
|
||||||
|
"- Être concrètes et utiles en temps réel : descriptions sensorielles, "
|
||||||
|
"réactions de PNJ cohérentes avec leur fiche, suggestions de complications "
|
||||||
|
"qui s'enchaînent à ce qui vient de se passer.\n"
|
||||||
|
"- Éviter les longs développements : le MJ est en train d'animer une partie, "
|
||||||
|
"il a besoin d'idées immédiatement actionnables."
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_narrative_entity(ne: NarrativeEntityContext) -> str:
|
def _format_narrative_entity(ne: NarrativeEntityContext) -> str:
|
||||||
"""Bloc équivalent à _format_page mais pour Arc/Chapter/Scene."""
|
"""Bloc équivalent à _format_page mais pour Arc/Chapter/Scene."""
|
||||||
|
|||||||
@@ -229,3 +229,30 @@ class GameSystemContext:
|
|||||||
system_name: str
|
system_name: str
|
||||||
system_description: str | None
|
system_description: str | None
|
||||||
sections: dict[str, str]
|
sections: dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class JournalEntrySummary:
|
||||||
|
"""Une entrée du journal d'une Session : type + contenu + horodatage."""
|
||||||
|
|
||||||
|
type: str
|
||||||
|
content: str
|
||||||
|
occurred_at: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SessionContext:
|
||||||
|
"""Contexte d'une Session de jeu en cours (Play Context).
|
||||||
|
|
||||||
|
Injecté dans le system prompt pendant qu'une partie est jouée pour que
|
||||||
|
l'IA voit le nom de la session, son statut, et un historique chronologique
|
||||||
|
des évènements/notes/jets capturés par le MJ.
|
||||||
|
|
||||||
|
Le journal a déjà été tronqué côté Core (cap à ~80 entrées récentes)
|
||||||
|
pour ne pas saturer le contexte LLM sur les sessions très longues.
|
||||||
|
"""
|
||||||
|
|
||||||
|
session_name: str
|
||||||
|
active: bool
|
||||||
|
started_at: str | None
|
||||||
|
entries: list[JournalEntrySummary]
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from app.domain.models import (
|
|||||||
NpcSummary,
|
NpcSummary,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
GameSystemContext,
|
GameSystemContext,
|
||||||
|
JournalEntrySummary,
|
||||||
LoreStructuralContext,
|
LoreStructuralContext,
|
||||||
NarrativeEntityContext,
|
NarrativeEntityContext,
|
||||||
PageContext,
|
PageContext,
|
||||||
@@ -33,6 +34,7 @@ from app.domain.models import (
|
|||||||
PageSummary,
|
PageSummary,
|
||||||
SceneBranchHint,
|
SceneBranchHint,
|
||||||
SceneSummary,
|
SceneSummary,
|
||||||
|
SessionContext,
|
||||||
)
|
)
|
||||||
from app.domain.ports import LLMProvider, LLMProviderError
|
from app.domain.ports import LLMProvider, LLMProviderError
|
||||||
from app.infrastructure.ollama_adapter import OllamaLLMProvider
|
from app.infrastructure.ollama_adapter import OllamaLLMProvider
|
||||||
@@ -41,7 +43,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="LoreMind Brain",
|
title="LoreMind Brain",
|
||||||
description="Backend IA pour la génération de contenu narratif.",
|
description="Backend IA pour la génération de contenu narratif.",
|
||||||
version="0.8.3",
|
version="0.9.0-beta",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -243,13 +245,34 @@ class GameSystemContextDTO(BaseModel):
|
|||||||
sections: dict[str, str] = Field(default_factory=dict)
|
sections: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class JournalEntrySummaryDTO(BaseModel):
|
||||||
|
"""Une entrée du journal de session — type + contenu + horodatage."""
|
||||||
|
|
||||||
|
type: str
|
||||||
|
content: str
|
||||||
|
occurred_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SessionContextDTO(BaseModel):
|
||||||
|
"""Contexte d'une Session de jeu en cours (Play Context).
|
||||||
|
|
||||||
|
Injecté par le Core quand le chat est ancré sur une Session.
|
||||||
|
Contient le journal chronologique (déjà plafonné côté Core).
|
||||||
|
"""
|
||||||
|
|
||||||
|
session_name: str
|
||||||
|
active: bool
|
||||||
|
started_at: str | None = None
|
||||||
|
entries: list[JournalEntrySummaryDTO] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class ChatStreamRequestDTO(BaseModel):
|
class ChatStreamRequestDTO(BaseModel):
|
||||||
"""Requête de chat streamé : historique + contextes structurels.
|
"""Requête de chat streamé : historique + contextes structurels.
|
||||||
|
|
||||||
Les 4 contextes (lore, page, campaign, narrative_entity) sont optionnels,
|
Les contextes (lore, page, campaign, narrative_entity, session) sont
|
||||||
mais au moins l'un des deux "niveaux haut" (lore_context ou
|
optionnels, mais au moins l'un des contextes "racines" (lore_context,
|
||||||
campaign_context) doit être fourni. Le validateur `check_scope` applique
|
campaign_context ou session_context) doit être fourni. Le validateur
|
||||||
cette règle à la frontière HTTP.
|
`check_scope` applique cette règle à la frontière HTTP.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
messages: list[ChatMessageDTO] = Field(min_length=1)
|
messages: list[ChatMessageDTO] = Field(min_length=1)
|
||||||
@@ -258,10 +281,15 @@ class ChatStreamRequestDTO(BaseModel):
|
|||||||
campaign_context: CampaignContextDTO | None = None
|
campaign_context: CampaignContextDTO | None = None
|
||||||
narrative_entity: NarrativeEntityDTO | None = None
|
narrative_entity: NarrativeEntityDTO | None = None
|
||||||
game_system_context: GameSystemContextDTO | None = None
|
game_system_context: GameSystemContextDTO | None = None
|
||||||
|
session_context: SessionContextDTO | None = None
|
||||||
|
|
||||||
def has_scope(self) -> bool:
|
def has_scope(self) -> bool:
|
||||||
"""Vrai si au moins un contexte racine (Lore ou Campagne) est fourni."""
|
"""Vrai si au moins un contexte racine (Lore, Campagne ou Session) est fourni."""
|
||||||
return self.lore_context is not None or self.campaign_context is not None
|
return (
|
||||||
|
self.lore_context is not None
|
||||||
|
or self.campaign_context is not None
|
||||||
|
or self.session_context is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# --- Factories d'injection de dépendance ---
|
# --- Factories d'injection de dépendance ---
|
||||||
@@ -385,6 +413,7 @@ async def chat_stream(
|
|||||||
campaign_context = _to_campaign_context(body.campaign_context)
|
campaign_context = _to_campaign_context(body.campaign_context)
|
||||||
narrative_entity = _to_narrative_entity(body.narrative_entity)
|
narrative_entity = _to_narrative_entity(body.narrative_entity)
|
||||||
game_system_context = _to_game_system_context(body.game_system_context)
|
game_system_context = _to_game_system_context(body.game_system_context)
|
||||||
|
session_context = _to_session_context(body.session_context)
|
||||||
|
|
||||||
# --- Comptage tokens pour la jauge de contexte frontend ---
|
# --- Comptage tokens pour la jauge de contexte frontend ---
|
||||||
# On construit le system prompt une fois ici pour le compter — le use case
|
# On construit le system prompt une fois ici pour le compter — le use case
|
||||||
@@ -397,6 +426,7 @@ async def chat_stream(
|
|||||||
campaign_context=campaign_context,
|
campaign_context=campaign_context,
|
||||||
narrative_entity=narrative_entity,
|
narrative_entity=narrative_entity,
|
||||||
game_system_context=game_system_context,
|
game_system_context=game_system_context,
|
||||||
|
session_context=session_context,
|
||||||
)
|
)
|
||||||
# Dernier message = "current" (souvent user), le reste = historique accumulé.
|
# Dernier message = "current" (souvent user), le reste = historique accumulé.
|
||||||
current_msg = messages[-1] if messages else None
|
current_msg = messages[-1] if messages else None
|
||||||
@@ -421,6 +451,7 @@ async def chat_stream(
|
|||||||
campaign_context=campaign_context,
|
campaign_context=campaign_context,
|
||||||
narrative_entity=narrative_entity,
|
narrative_entity=narrative_entity,
|
||||||
game_system_context=game_system_context,
|
game_system_context=game_system_context,
|
||||||
|
session_context=session_context,
|
||||||
):
|
):
|
||||||
# json.dumps avec ensure_ascii=False pour préserver les accents
|
# json.dumps avec ensure_ascii=False pour préserver les accents
|
||||||
yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n"
|
yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n"
|
||||||
@@ -867,3 +898,22 @@ def _to_game_system_context(dto: GameSystemContextDTO | None) -> GameSystemConte
|
|||||||
system_description=dto.system_description,
|
system_description=dto.system_description,
|
||||||
sections=dict(dto.sections),
|
sections=dict(dto.sections),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_session_context(dto: SessionContextDTO | None) -> SessionContext | None:
|
||||||
|
if dto is None:
|
||||||
|
return None
|
||||||
|
entries = [
|
||||||
|
JournalEntrySummary(
|
||||||
|
type=e.type,
|
||||||
|
content=e.content,
|
||||||
|
occurred_at=e.occurred_at,
|
||||||
|
)
|
||||||
|
for e in dto.entries
|
||||||
|
]
|
||||||
|
return SessionContext(
|
||||||
|
session_name=dto.session_name,
|
||||||
|
active=dto.active,
|
||||||
|
started_at=dto.started_at,
|
||||||
|
entries=entries,
|
||||||
|
)
|
||||||
|
|||||||
13
core/pom.xml
13
core/pom.xml
@@ -8,13 +8,13 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-parent</artifactId>
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
<version>3.2.0</version>
|
<version>3.2.12</version>
|
||||||
<relativePath/>
|
<relativePath/>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<groupId>com.loremind</groupId>
|
<groupId>com.loremind</groupId>
|
||||||
<artifactId>loremind-core</artifactId>
|
<artifactId>loremind-core</artifactId>
|
||||||
<version>0.8.3</version>
|
<version>0.9.0-beta</version>
|
||||||
<name>LoreMind Core</name>
|
<name>LoreMind Core</name>
|
||||||
<description>Backend Core - Architecture Hexagonale</description>
|
<description>Backend Core - Architecture Hexagonale</description>
|
||||||
|
|
||||||
@@ -96,6 +96,15 @@
|
|||||||
<artifactId>bcprov-jdk18on</artifactId>
|
<artifactId>bcprov-jdk18on</artifactId>
|
||||||
<version>1.78.1</version>
|
<version>1.78.1</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- Google Tink : runtime requis par com.nimbusds.jose.crypto.Ed25519Verifier
|
||||||
|
(depuis Nimbus 9.x, la verification EdDSA delegue a Tink.subtle.Ed25519Verify).
|
||||||
|
Tink n'est PAS une dependance transitive de nimbus-jose-jwt → il faut
|
||||||
|
l'ajouter explicitement, sinon NoClassDefFoundError au premier verify(). -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.crypto.tink</groupId>
|
||||||
|
<artifactId>tink</artifactId>
|
||||||
|
<version>1.14.1</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.loremind.application.generationcontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.generationcontext.SessionContext;
|
||||||
|
import com.loremind.domain.generationcontext.SessionContext.JournalEntrySummary;
|
||||||
|
import com.loremind.domain.playcontext.Session;
|
||||||
|
import com.loremind.domain.playcontext.SessionEntry;
|
||||||
|
import com.loremind.domain.playcontext.ports.SessionEntryRepository;
|
||||||
|
import com.loremind.domain.playcontext.ports.SessionRepository;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit le SessionContext injecté dans le prompt IA pendant une partie.
|
||||||
|
*
|
||||||
|
* <p>Charge la Session + les N dernières entrées du journal et les mappe vers
|
||||||
|
* le Value Object {@link SessionContext}. La limite d'entrées évite de saturer
|
||||||
|
* la fenêtre de contexte du LLM sur des sessions très longues.</p>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class SessionStructuralContextBuilder {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plafond du nombre d'entrées remontées au LLM.
|
||||||
|
* Choisi pour rester dans des limites raisonnables (≈ 5-10k tokens max
|
||||||
|
* pour des entrées moyennes de 200 chars). Si la session déborde,
|
||||||
|
* on garde les entrées les plus récentes (fin de chronologie).
|
||||||
|
*/
|
||||||
|
private static final int MAX_ENTRIES = 80;
|
||||||
|
|
||||||
|
private final SessionRepository sessionRepository;
|
||||||
|
private final SessionEntryRepository entryRepository;
|
||||||
|
|
||||||
|
public SessionStructuralContextBuilder(SessionRepository sessionRepository,
|
||||||
|
SessionEntryRepository entryRepository) {
|
||||||
|
this.sessionRepository = sessionRepository;
|
||||||
|
this.entryRepository = entryRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<SessionContext> buildOptional(String sessionId) {
|
||||||
|
return sessionRepository.findById(sessionId).map(this::toContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionContext build(String sessionId) {
|
||||||
|
Session session = sessionRepository.findById(sessionId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
|
||||||
|
return toContext(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SessionContext toContext(Session session) {
|
||||||
|
List<SessionEntry> allEntries = entryRepository.findBySessionId(session.getId());
|
||||||
|
// findBySessionId renvoie en ASC. On garde la fin si la liste dépasse le plafond
|
||||||
|
// — c'est l'info récente qui aide le plus l'IA pendant la partie.
|
||||||
|
List<SessionEntry> kept = allEntries.size() <= MAX_ENTRIES
|
||||||
|
? allEntries
|
||||||
|
: allEntries.subList(allEntries.size() - MAX_ENTRIES, allEntries.size());
|
||||||
|
|
||||||
|
List<JournalEntrySummary> summaries = kept.stream()
|
||||||
|
.map(e -> new JournalEntrySummary(
|
||||||
|
e.getType() != null ? e.getType().name() : "NOTE",
|
||||||
|
e.getContent(),
|
||||||
|
e.getOccurredAt()))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
return new SessionContext(
|
||||||
|
session.getName(),
|
||||||
|
session.isActive(),
|
||||||
|
session.getStartedAt(),
|
||||||
|
summaries);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package com.loremind.application.generationcontext;
|
||||||
|
|
||||||
|
import com.loremind.application.gamesystemcontext.GameSystemContextBuilder;
|
||||||
|
import com.loremind.domain.campaigncontext.Campaign;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||||
|
import com.loremind.domain.gamesystemcontext.GenerationIntent;
|
||||||
|
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||||
|
import com.loremind.domain.generationcontext.ChatMessage;
|
||||||
|
import com.loremind.domain.generationcontext.ChatRequest;
|
||||||
|
import com.loremind.domain.generationcontext.ChatUsage;
|
||||||
|
import com.loremind.domain.generationcontext.GameSystemContext;
|
||||||
|
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
||||||
|
import com.loremind.domain.generationcontext.SessionContext;
|
||||||
|
import com.loremind.domain.generationcontext.ports.AiChatProvider;
|
||||||
|
import com.loremind.domain.playcontext.Session;
|
||||||
|
import com.loremind.domain.playcontext.ports.SessionRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case applicatif : chat IA pendant une Session de jeu.
|
||||||
|
* <p>
|
||||||
|
* Orchestre la composition des contextes :
|
||||||
|
* 1. Charge la Session puis la Campagne associée (weak reference).
|
||||||
|
* 2. Construit le CampaignStructuralContext (carte narrative + PJ/PNJ).
|
||||||
|
* 3. Construit le LoreStructuralContext si la campagne est liée à un Lore.
|
||||||
|
* 4. Construit le GameSystemContext si elle a un système de JDR.
|
||||||
|
* 5. Construit le SessionContext (journal horodaté, statut).
|
||||||
|
* 6. Délègue au port {@link AiChatProvider} pour le streaming.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <p>La conversation est éphémère (pas de persistance) : pendant une partie,
|
||||||
|
* l'utilité est d'avoir une assistance immédiate, pas de garder un historique.
|
||||||
|
* Le journal de session joue déjà ce rôle de mémoire persistante.</p>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class StreamChatForSessionUseCase {
|
||||||
|
|
||||||
|
private final SessionRepository sessionRepository;
|
||||||
|
private final CampaignRepository campaignRepository;
|
||||||
|
private final CampaignStructuralContextBuilder campaignContextBuilder;
|
||||||
|
private final LoreStructuralContextBuilder loreContextBuilder;
|
||||||
|
private final GameSystemContextBuilder gameSystemContextBuilder;
|
||||||
|
private final SessionStructuralContextBuilder sessionContextBuilder;
|
||||||
|
private final AiChatProvider aiChatProvider;
|
||||||
|
|
||||||
|
public StreamChatForSessionUseCase(
|
||||||
|
SessionRepository sessionRepository,
|
||||||
|
CampaignRepository campaignRepository,
|
||||||
|
CampaignStructuralContextBuilder campaignContextBuilder,
|
||||||
|
LoreStructuralContextBuilder loreContextBuilder,
|
||||||
|
GameSystemContextBuilder gameSystemContextBuilder,
|
||||||
|
SessionStructuralContextBuilder sessionContextBuilder,
|
||||||
|
AiChatProvider aiChatProvider) {
|
||||||
|
this.sessionRepository = sessionRepository;
|
||||||
|
this.campaignRepository = campaignRepository;
|
||||||
|
this.campaignContextBuilder = campaignContextBuilder;
|
||||||
|
this.loreContextBuilder = loreContextBuilder;
|
||||||
|
this.gameSystemContextBuilder = gameSystemContextBuilder;
|
||||||
|
this.sessionContextBuilder = sessionContextBuilder;
|
||||||
|
this.aiChatProvider = aiChatProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void execute(
|
||||||
|
String sessionId,
|
||||||
|
List<ChatMessage> messages,
|
||||||
|
Consumer<ChatUsage> onUsage,
|
||||||
|
Consumer<String> onToken,
|
||||||
|
Runnable onComplete,
|
||||||
|
Consumer<Throwable> onError) {
|
||||||
|
|
||||||
|
Session session = sessionRepository.findById(sessionId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
|
||||||
|
|
||||||
|
Campaign campaign = campaignRepository.findById(session.getCampaignId())
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException(
|
||||||
|
"Campagne associée à la session introuvable : " + session.getCampaignId()));
|
||||||
|
|
||||||
|
CampaignStructuralContext campaignContext = campaignContextBuilder.build(campaign.getId());
|
||||||
|
LoreStructuralContext loreContext = loadLoreContextOrNull(campaign);
|
||||||
|
GameSystemContext gameSystemContext = loadGameSystemContextOrNull(campaign);
|
||||||
|
SessionContext sessionContext = sessionContextBuilder.build(sessionId);
|
||||||
|
|
||||||
|
ChatRequest request = ChatRequest.builder()
|
||||||
|
.messages(messages)
|
||||||
|
.loreContext(loreContext)
|
||||||
|
.campaignContext(campaignContext)
|
||||||
|
.gameSystemContext(gameSystemContext)
|
||||||
|
.sessionContext(sessionContext)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError);
|
||||||
|
}
|
||||||
|
|
||||||
|
private LoreStructuralContext loadLoreContextOrNull(Campaign campaign) {
|
||||||
|
if (!campaign.isLinkedToLore()) return null;
|
||||||
|
return loreContextBuilder.buildOptional(campaign.getLoreId()).orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pendant une session active, on injecte les sections les plus utiles en partie
|
||||||
|
* (combats, PNJ, mécaniques) — intent SCENE est le plus proche de ce besoin.
|
||||||
|
*/
|
||||||
|
private GameSystemContext loadGameSystemContextOrNull(Campaign campaign) {
|
||||||
|
if (!campaign.isLinkedToGameSystem()) return null;
|
||||||
|
return gameSystemContextBuilder.buildOptional(campaign.getGameSystemId(), GenerationIntent.SCENE)
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package com.loremind.application.licensing;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestre la bascule de canal stable <-> beta via le sidecar `switcher`.
|
||||||
|
*
|
||||||
|
* <p>Le sidecar tourne en permanence et watch un fichier {@code command.json}
|
||||||
|
* dans un volume partage. Quand on depose une commande, il :
|
||||||
|
* <ol>
|
||||||
|
* <li>Sed la ligne IMAGE_NAMESPACE du .env</li>
|
||||||
|
* <li>Lance docker compose pull + up -d</li>
|
||||||
|
* <li>Ecrit son resultat dans {@code result.json}</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>Le Core n'a PAS acces au socket Docker — il delegue tout au sidecar
|
||||||
|
* via fichiers, ce qui evite que la compromission du Core ne donne RCE
|
||||||
|
* sur l'hote. Le sidecar valide strictement le contenu de la commande
|
||||||
|
* (channel ∈ {stable, beta} uniquement).
|
||||||
|
*
|
||||||
|
* <p>Le canal actuel se deduit du prefixe d'image courant (recupere via
|
||||||
|
* la variable d'env {@code IMAGE_NAMESPACE} ou {@code UPDATE_CHECK_IMAGES}) :
|
||||||
|
* presence de "loremind-beta-" => canal beta, sinon stable.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ChannelSwitcherService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ChannelSwitcherService.class);
|
||||||
|
|
||||||
|
public enum Channel { STABLE, BETA }
|
||||||
|
|
||||||
|
public enum SwitchStatus { IN_PROGRESS, SUCCESS, ERROR }
|
||||||
|
|
||||||
|
/** Snapshot du dernier resultat de switch ecrit par le sidecar. */
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public record SwitchResult(
|
||||||
|
String id,
|
||||||
|
SwitchStatus status,
|
||||||
|
Channel channel,
|
||||||
|
String message,
|
||||||
|
Instant completedAt) {}
|
||||||
|
|
||||||
|
private final Path switcherDataPath;
|
||||||
|
private final String imageNamespace;
|
||||||
|
private final ObjectMapper json = new ObjectMapper();
|
||||||
|
|
||||||
|
public ChannelSwitcherService(
|
||||||
|
@Value("${SWITCHER_DATA_PATH:/shared/switcher}") String switcherDataPath,
|
||||||
|
// On lit IMAGE_NAMESPACE en priorite, puis UPDATE_CHECK_IMAGES en fallback
|
||||||
|
// (la deuxieme est toujours injectee par compose, contrairement a la premiere
|
||||||
|
// qui peut etre absente dans les .env legacy).
|
||||||
|
@Value("${IMAGE_NAMESPACE:${UPDATE_CHECK_IMAGES:}}") String imageNamespaceRaw) {
|
||||||
|
this.switcherDataPath = Path.of(switcherDataPath);
|
||||||
|
this.imageNamespace = imageNamespaceRaw != null ? imageNamespaceRaw : "";
|
||||||
|
log.info("ChannelSwitcherService initialized: dataPath={} imageNamespace={}",
|
||||||
|
switcherDataPath, this.imageNamespace);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detection du canal courant a partir du prefixe d'image charge au demarrage.
|
||||||
|
* Pas de magie : si le namespace contient "beta-" on est en beta, sinon stable.
|
||||||
|
*/
|
||||||
|
public Channel getCurrentChannel() {
|
||||||
|
return imageNamespace.contains("loremind-beta-") ? Channel.BETA : Channel.STABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indique si le sidecar est disponible (volume partage accessible).
|
||||||
|
* Si non, on degrade en lecture seule (l'UI affichera l'ancien message
|
||||||
|
* avec instructions manuelles).
|
||||||
|
*/
|
||||||
|
public boolean isSwitcherAvailable() {
|
||||||
|
return Files.isDirectory(switcherDataPath) && Files.isWritable(switcherDataPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Depose une commande de switch dans le volume partage. Renvoie l'ID
|
||||||
|
* de la commande, que le client peut utiliser pour poller le status.
|
||||||
|
*
|
||||||
|
* @throws IllegalStateException si le sidecar n'est pas disponible
|
||||||
|
* @throws IOException si l'ecriture du fichier echoue
|
||||||
|
*/
|
||||||
|
public String requestSwitch(Channel target) throws IOException {
|
||||||
|
if (!isSwitcherAvailable()) {
|
||||||
|
throw new IllegalStateException("Switcher sidecar not available (volume mount missing)");
|
||||||
|
}
|
||||||
|
String id = UUID.randomUUID().toString();
|
||||||
|
Map<String, Object> command = new LinkedHashMap<>();
|
||||||
|
command.put("id", id);
|
||||||
|
command.put("channel", target.name().toLowerCase());
|
||||||
|
command.put("requestedAt", Instant.now().toString());
|
||||||
|
|
||||||
|
Path commandFile = switcherDataPath.resolve("command.json");
|
||||||
|
Path tmp = Files.createTempFile(switcherDataPath, "command-", ".tmp");
|
||||||
|
try {
|
||||||
|
json.writerWithDefaultPrettyPrinter().writeValue(tmp.toFile(), command);
|
||||||
|
// Atomic move : evite que le sidecar lise un fichier partiellement ecrit.
|
||||||
|
Files.move(tmp, commandFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||||
|
} finally {
|
||||||
|
// Cleanup au cas ou move aurait echoue avant le rename.
|
||||||
|
Files.deleteIfExists(tmp);
|
||||||
|
}
|
||||||
|
log.info("Switch command written: id={} channel={}", id, target);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lit le dernier resultat ecrit par le sidecar, s'il existe.
|
||||||
|
* Renvoie null si aucun switch n'a encore ete tente sur cette instance.
|
||||||
|
*/
|
||||||
|
public SwitchResult getLastResult() {
|
||||||
|
Path resultFile = switcherDataPath.resolve("result.json");
|
||||||
|
if (!Files.exists(resultFile)) return null;
|
||||||
|
try {
|
||||||
|
return json.readValue(resultFile.toFile(), SwitchResult.class);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("Cannot parse switcher result.json: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package com.loremind.application.playcontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.playcontext.EntryType;
|
||||||
|
import com.loremind.domain.playcontext.SessionEntry;
|
||||||
|
import com.loremind.domain.playcontext.ports.SessionEntryRepository;
|
||||||
|
import com.loremind.domain.playcontext.ports.SessionRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service d'application pour le journal d'une Session.
|
||||||
|
* Gère le cycle CRUD des entrées (note, évènement, jet, action joueur).
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class SessionEntryService {
|
||||||
|
|
||||||
|
private final SessionEntryRepository entryRepository;
|
||||||
|
private final SessionRepository sessionRepository;
|
||||||
|
|
||||||
|
public SessionEntryService(SessionEntryRepository entryRepository,
|
||||||
|
SessionRepository sessionRepository) {
|
||||||
|
this.entryRepository = entryRepository;
|
||||||
|
this.sessionRepository = sessionRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Données fournies par l'API pour créer ou éditer une entrée. */
|
||||||
|
public record EntryData(EntryType type, String content, LocalDateTime occurredAt) {}
|
||||||
|
|
||||||
|
public SessionEntry createEntry(String sessionId, EntryData data) {
|
||||||
|
if (sessionId == null || sessionId.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("sessionId est requis.");
|
||||||
|
}
|
||||||
|
if (!sessionRepository.existsById(sessionId)) {
|
||||||
|
throw new IllegalArgumentException("Session introuvable : " + sessionId);
|
||||||
|
}
|
||||||
|
validateContent(data.content());
|
||||||
|
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
SessionEntry entry = SessionEntry.builder()
|
||||||
|
.sessionId(sessionId)
|
||||||
|
.type(data.type() != null ? data.type() : EntryType.NOTE)
|
||||||
|
.content(data.content().trim())
|
||||||
|
.occurredAt(data.occurredAt() != null ? data.occurredAt() : now)
|
||||||
|
.build();
|
||||||
|
return entryRepository.save(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionEntry updateEntry(String id, EntryData data) {
|
||||||
|
validateContent(data.content());
|
||||||
|
SessionEntry existing = entryRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Entrée introuvable : " + id));
|
||||||
|
if (data.type() != null) existing.setType(data.type());
|
||||||
|
existing.setContent(data.content().trim());
|
||||||
|
if (data.occurredAt() != null) existing.setOccurredAt(data.occurredAt());
|
||||||
|
return entryRepository.save(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<SessionEntry> getById(String id) {
|
||||||
|
return entryRepository.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<SessionEntry> getBySessionId(String sessionId) {
|
||||||
|
return entryRepository.findBySessionId(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteEntry(String id) {
|
||||||
|
if (!entryRepository.existsById(id)) {
|
||||||
|
throw new IllegalArgumentException("Entrée introuvable : " + id);
|
||||||
|
}
|
||||||
|
entryRepository.deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateContent(String content) {
|
||||||
|
if (content == null || content.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Le contenu d'une entrée ne peut pas être vide.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package com.loremind.application.playcontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||||
|
import com.loremind.domain.playcontext.Session;
|
||||||
|
import com.loremind.domain.playcontext.ports.SessionEntryRepository;
|
||||||
|
import com.loremind.domain.playcontext.ports.SessionRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service d'application pour le Play Context.
|
||||||
|
* Orchestre le cycle de vie d'une Session (lancement, fin, renommage).
|
||||||
|
* Fait partie de la couche Application de l'Architecture Hexagonale.
|
||||||
|
*
|
||||||
|
* <p>Règle métier : une seule Session peut être active (endedAt null) à la fois
|
||||||
|
* dans l'application.</p>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class SessionService {
|
||||||
|
|
||||||
|
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||||
|
|
||||||
|
private final SessionRepository sessionRepository;
|
||||||
|
private final SessionEntryRepository entryRepository;
|
||||||
|
private final CampaignRepository campaignRepository;
|
||||||
|
|
||||||
|
public SessionService(SessionRepository sessionRepository,
|
||||||
|
SessionEntryRepository entryRepository,
|
||||||
|
CampaignRepository campaignRepository) {
|
||||||
|
this.sessionRepository = sessionRepository;
|
||||||
|
this.entryRepository = entryRepository;
|
||||||
|
this.campaignRepository = campaignRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lance une nouvelle session sur la campagne donnée.
|
||||||
|
* Échoue si une session est déjà active ou si la campagne n'existe pas.
|
||||||
|
*/
|
||||||
|
public Session startSession(String campaignId) {
|
||||||
|
if (campaignId == null || campaignId.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("campaignId est requis pour démarrer une session.");
|
||||||
|
}
|
||||||
|
if (!campaignRepository.existsById(campaignId)) {
|
||||||
|
throw new IllegalArgumentException("Campagne introuvable : " + campaignId);
|
||||||
|
}
|
||||||
|
sessionRepository.findActive().ifPresent(s -> {
|
||||||
|
throw new IllegalStateException("Une session est déjà en cours (id=" + s.getId() + "). Termine-la avant d'en lancer une nouvelle.");
|
||||||
|
});
|
||||||
|
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
Session session = Session.builder()
|
||||||
|
.name(generateDefaultName(now))
|
||||||
|
.campaignId(campaignId)
|
||||||
|
.startedAt(now)
|
||||||
|
.build();
|
||||||
|
return sessionRepository.save(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Termine la session active si elle correspond à l'id donné. */
|
||||||
|
public Session endSession(String id) {
|
||||||
|
Session session = sessionRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id));
|
||||||
|
if (!session.isActive()) {
|
||||||
|
throw new IllegalStateException("Cette session est déjà terminée.");
|
||||||
|
}
|
||||||
|
session.setEndedAt(LocalDateTime.now());
|
||||||
|
return sessionRepository.save(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Session renameSession(String id, String newName) {
|
||||||
|
if (newName == null || newName.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Le nom de la session ne peut pas être vide.");
|
||||||
|
}
|
||||||
|
Session session = sessionRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id));
|
||||||
|
session.setName(newName.trim());
|
||||||
|
return sessionRepository.save(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<Session> getById(String id) {
|
||||||
|
return sessionRepository.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<Session> getActive() {
|
||||||
|
return sessionRepository.findActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Session> getAll() {
|
||||||
|
return sessionRepository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Session> getByCampaignId(String campaignId) {
|
||||||
|
return sessionRepository.findByCampaignId(campaignId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime une session et toutes ses entrées de journal en cascade.
|
||||||
|
* Transactionnel : soit tout disparaît, soit rien.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void deleteSession(String id) {
|
||||||
|
if (!sessionRepository.existsById(id)) {
|
||||||
|
throw new IllegalArgumentException("Session introuvable : " + id);
|
||||||
|
}
|
||||||
|
entryRepository.deleteBySessionId(id);
|
||||||
|
sessionRepository.deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateDefaultName(LocalDateTime startedAt) {
|
||||||
|
return "Session du " + startedAt.format(DATE_FORMATTER);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,8 @@ public record ChatRequest(
|
|||||||
PageContext pageContext,
|
PageContext pageContext,
|
||||||
CampaignStructuralContext campaignContext,
|
CampaignStructuralContext campaignContext,
|
||||||
NarrativeEntityContext narrativeEntity,
|
NarrativeEntityContext narrativeEntity,
|
||||||
GameSystemContext gameSystemContext) {
|
GameSystemContext gameSystemContext,
|
||||||
|
SessionContext sessionContext) {
|
||||||
|
|
||||||
public static Builder builder() {
|
public static Builder builder() {
|
||||||
return new Builder();
|
return new Builder();
|
||||||
@@ -50,6 +51,7 @@ public record ChatRequest(
|
|||||||
private CampaignStructuralContext campaignContext;
|
private CampaignStructuralContext campaignContext;
|
||||||
private NarrativeEntityContext narrativeEntity;
|
private NarrativeEntityContext narrativeEntity;
|
||||||
private GameSystemContext gameSystemContext;
|
private GameSystemContext gameSystemContext;
|
||||||
|
private SessionContext sessionContext;
|
||||||
|
|
||||||
private Builder() {}
|
private Builder() {}
|
||||||
|
|
||||||
@@ -83,9 +85,14 @@ public record ChatRequest(
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Builder sessionContext(SessionContext sessionContext) {
|
||||||
|
this.sessionContext = sessionContext;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public ChatRequest build() {
|
public ChatRequest build() {
|
||||||
return new ChatRequest(messages, loreContext, pageContext,
|
return new ChatRequest(messages, loreContext, pageContext,
|
||||||
campaignContext, narrativeEntity, gameSystemContext);
|
campaignContext, narrativeEntity, gameSystemContext, sessionContext);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.loremind.domain.generationcontext;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contexte structurel d'une Session de jeu — injecté dans le system prompt
|
||||||
|
* de l'IA pour qu'elle ait conscience de la partie en cours et de son journal.
|
||||||
|
*
|
||||||
|
* <p>Pendant qu'une session se joue, l'IA reçoit en plus du Lore/Campagne/GameSystem :
|
||||||
|
* le nom de la session, son statut (en cours / terminée) et un résumé chronologique
|
||||||
|
* des entrées du journal (notes, évènements, jets, actions joueurs).</p>
|
||||||
|
*
|
||||||
|
* <p>Value Object du Generation Context — record Java immutable.</p>
|
||||||
|
*
|
||||||
|
* @param sessionName Nom de la session telle qu'affichée au MJ.
|
||||||
|
* @param active True si la session est en cours, false si terminée.
|
||||||
|
* @param startedAt Horodatage de démarrage.
|
||||||
|
* @param entries Entrées du journal triées chronologiquement (anciennes → récentes).
|
||||||
|
* Limité côté builder pour éviter de saturer le contexte LLM.
|
||||||
|
*/
|
||||||
|
public record SessionContext(
|
||||||
|
String sessionName,
|
||||||
|
boolean active,
|
||||||
|
LocalDateTime startedAt,
|
||||||
|
List<JournalEntrySummary> entries) {
|
||||||
|
|
||||||
|
/** Résumé d'une entrée de journal — type + contenu + horodatage. */
|
||||||
|
public record JournalEntrySummary(
|
||||||
|
String type,
|
||||||
|
String content,
|
||||||
|
LocalDateTime occurredAt) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.loremind.domain.playcontext;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type d'entrée du journal de session.
|
||||||
|
* Permet à l'UI de catégoriser visuellement la timeline (icône, couleur).
|
||||||
|
*/
|
||||||
|
public enum EntryType {
|
||||||
|
/** Note libre du MJ (défaut). */
|
||||||
|
NOTE,
|
||||||
|
/** Moment marquant du scénario (combat gagné, décision majeure...). */
|
||||||
|
EVENT,
|
||||||
|
/** Jet de dés / test de caractéristique. */
|
||||||
|
DICE_ROLL,
|
||||||
|
/** Action déclarée par un joueur. */
|
||||||
|
PLAYER_ACTION
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.loremind.domain.playcontext;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entité de domaine représentant une Session de jeu en cours ou passée.
|
||||||
|
*
|
||||||
|
* <p>Une Session est une instance jouée d'une Campaign. La Campaign reste
|
||||||
|
* un scénario générique réutilisable ; la Session capture une partie réelle
|
||||||
|
* (date, journal, etc.) sans polluer le scénario d'origine.</p>
|
||||||
|
*
|
||||||
|
* <p>Fait partie du Play Context. Référence la Campaign par weak reference
|
||||||
|
* (campaignId) pour respecter la séparation des Bounded Contexts.</p>
|
||||||
|
*
|
||||||
|
* <p>{@code endedAt == null} signifie que la session est en cours.
|
||||||
|
* Une seule session peut être en cours dans l'application à la fois.</p>
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class Session {
|
||||||
|
|
||||||
|
private String id;
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/** Weak reference vers Campaign — pas de dépendance directe inter-contexte. */
|
||||||
|
private String campaignId;
|
||||||
|
|
||||||
|
private LocalDateTime startedAt;
|
||||||
|
|
||||||
|
/** Null = session en cours ; renseigné = session terminée. */
|
||||||
|
private LocalDateTime endedAt;
|
||||||
|
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
public boolean isActive() {
|
||||||
|
return this.endedAt == null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.loremind.domain.playcontext;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entrée du journal d'une Session.
|
||||||
|
* Représente un évènement horodaté capturé pendant ou après une partie :
|
||||||
|
* note libre du MJ, évènement marquant, jet de dés, action de joueur.
|
||||||
|
*
|
||||||
|
* <p>Fait partie du Play Context. Référence la Session par weak reference
|
||||||
|
* (sessionId) — l'orchestration en cascade est gérée par le service applicatif.</p>
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class SessionEntry {
|
||||||
|
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
/** Weak reference vers Session (intra-contexte mais reste découplée). */
|
||||||
|
private String sessionId;
|
||||||
|
|
||||||
|
private EntryType type;
|
||||||
|
|
||||||
|
/** Contenu texte brut saisi par le MJ. */
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Horodatage métier de l'évènement.
|
||||||
|
* Distinct de {@code createdAt} : utile si le MJ rédige a posteriori
|
||||||
|
* une note rétroactive sur quelque chose qui s'est passé plus tôt.
|
||||||
|
*/
|
||||||
|
private LocalDateTime occurredAt;
|
||||||
|
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.loremind.domain.playcontext.ports;
|
||||||
|
|
||||||
|
import com.loremind.domain.playcontext.SessionEntry;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port de sortie pour la persistance des entrées de journal de session.
|
||||||
|
*/
|
||||||
|
public interface SessionEntryRepository {
|
||||||
|
|
||||||
|
SessionEntry save(SessionEntry entry);
|
||||||
|
|
||||||
|
Optional<SessionEntry> findById(String id);
|
||||||
|
|
||||||
|
/** Renvoie les entrées d'une session, triées par occurredAt croissant (chronologique). */
|
||||||
|
List<SessionEntry> findBySessionId(String sessionId);
|
||||||
|
|
||||||
|
void deleteById(String id);
|
||||||
|
|
||||||
|
/** Supprime toutes les entrées d'une session — utilisé pour la cascade à la suppression. */
|
||||||
|
void deleteBySessionId(String sessionId);
|
||||||
|
|
||||||
|
boolean existsById(String id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.loremind.domain.playcontext.ports;
|
||||||
|
|
||||||
|
import com.loremind.domain.playcontext.Session;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port de sortie pour la persistance des Sessions.
|
||||||
|
* Interface définie dans le domaine, implémentée par l'infrastructure.
|
||||||
|
*/
|
||||||
|
public interface SessionRepository {
|
||||||
|
|
||||||
|
Session save(Session session);
|
||||||
|
|
||||||
|
Optional<Session> findById(String id);
|
||||||
|
|
||||||
|
List<Session> findAll();
|
||||||
|
|
||||||
|
List<Session> findByCampaignId(String campaignId);
|
||||||
|
|
||||||
|
/** Retourne la session en cours (endedAt null) s'il y en a une. */
|
||||||
|
Optional<Session> findActive();
|
||||||
|
|
||||||
|
void deleteById(String id);
|
||||||
|
|
||||||
|
boolean existsById(String id);
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ import com.loremind.domain.generationcontext.LoreStructuralContext;
|
|||||||
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
|
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
|
||||||
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
||||||
import com.loremind.domain.generationcontext.PageContext;
|
import com.loremind.domain.generationcontext.PageContext;
|
||||||
|
import com.loremind.domain.generationcontext.SessionContext;
|
||||||
|
import com.loremind.domain.generationcontext.SessionContext.JournalEntrySummary;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
@@ -58,9 +60,35 @@ public class BrainChatPayloadBuilder {
|
|||||||
if (request.gameSystemContext() != null) {
|
if (request.gameSystemContext() != null) {
|
||||||
root.put("game_system_context", gameSystemContextToMap(request.gameSystemContext()));
|
root.put("game_system_context", gameSystemContextToMap(request.gameSystemContext()));
|
||||||
}
|
}
|
||||||
|
if (request.sessionContext() != null) {
|
||||||
|
root.put("session_context", sessionContextToMap(request.sessionContext()));
|
||||||
|
}
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> sessionContextToMap(SessionContext sc) {
|
||||||
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
|
map.put("session_name", sc.sessionName());
|
||||||
|
map.put("active", sc.active());
|
||||||
|
if (sc.startedAt() != null) {
|
||||||
|
map.put("started_at", sc.startedAt().toString());
|
||||||
|
}
|
||||||
|
map.put("entries", sc.entries() != null
|
||||||
|
? sc.entries().stream().map(this::journalEntryToMap).collect(Collectors.toList())
|
||||||
|
: List.of());
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> journalEntryToMap(JournalEntrySummary e) {
|
||||||
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
|
map.put("type", e.type());
|
||||||
|
map.put("content", e.content());
|
||||||
|
if (e.occurredAt() != null) {
|
||||||
|
map.put("occurred_at", e.occurredAt().toString());
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
private Map<String, Object> gameSystemContextToMap(GameSystemContext gs) {
|
private Map<String, Object> gameSystemContextToMap(GameSystemContext gs) {
|
||||||
Map<String, Object> map = new LinkedHashMap<>();
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
map.put("system_name", gs.systemName());
|
map.put("system_name", gs.systemName());
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.loremind.infrastructure.persistence.entity;
|
||||||
|
|
||||||
|
import com.loremind.domain.playcontext.EntryType;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entité JPA pour la persistance des entrées de journal de session.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "session_entries", indexes = {
|
||||||
|
@Index(name = "idx_session_entries_session_id", columnList = "session_id")
|
||||||
|
})
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class SessionEntryJpaEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** Weak reference — pas de FK DB pour rester cohérent avec le reste du projet. */
|
||||||
|
@Column(name = "session_id", nullable = false)
|
||||||
|
private String sessionId;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false, length = 32)
|
||||||
|
private EntryType type;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT", nullable = false)
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
@Column(name = "occurred_at", nullable = false)
|
||||||
|
private LocalDateTime occurredAt;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
createdAt = now;
|
||||||
|
updatedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
protected void onUpdate() {
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.loremind.infrastructure.persistence.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entité JPA pour la persistance des Sessions en PostgreSQL.
|
||||||
|
* Adaptateur d'infrastructure — n'est PAS dans le domaine.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "sessions")
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class SessionJpaEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ID de la Campaign associée. Pas de @ManyToOne / pas de FK : c'est une
|
||||||
|
* weak reference inter-contexte (Play Context ↔ Campaign Context).
|
||||||
|
*/
|
||||||
|
@Column(name = "campaign_id", nullable = false)
|
||||||
|
private String campaignId;
|
||||||
|
|
||||||
|
@Column(name = "started_at", nullable = false)
|
||||||
|
private LocalDateTime startedAt;
|
||||||
|
|
||||||
|
/** Null = session en cours. */
|
||||||
|
@Column(name = "ended_at")
|
||||||
|
private LocalDateTime endedAt;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
createdAt = now;
|
||||||
|
updatedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
protected void onUpdate() {
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.loremind.infrastructure.persistence.jpa;
|
||||||
|
|
||||||
|
import com.loremind.infrastructure.persistence.entity.SessionEntryJpaEntity;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface SessionEntryJpaRepository extends JpaRepository<SessionEntryJpaEntity, Long> {
|
||||||
|
|
||||||
|
List<SessionEntryJpaEntity> findBySessionIdOrderByOccurredAtAsc(String sessionId);
|
||||||
|
|
||||||
|
void deleteBySessionId(String sessionId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.loremind.infrastructure.persistence.jpa;
|
||||||
|
|
||||||
|
import com.loremind.infrastructure.persistence.entity.SessionJpaEntity;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository Spring Data JPA pour SessionJpaEntity.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public interface SessionJpaRepository extends JpaRepository<SessionJpaEntity, Long> {
|
||||||
|
|
||||||
|
List<SessionJpaEntity> findByCampaignIdOrderByStartedAtDesc(String campaignId);
|
||||||
|
|
||||||
|
Optional<SessionJpaEntity> findFirstByEndedAtIsNull();
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package com.loremind.infrastructure.persistence.postgres;
|
||||||
|
|
||||||
|
import com.loremind.domain.playcontext.SessionEntry;
|
||||||
|
import com.loremind.domain.playcontext.ports.SessionEntryRepository;
|
||||||
|
import com.loremind.infrastructure.persistence.entity.SessionEntryJpaEntity;
|
||||||
|
import com.loremind.infrastructure.persistence.jpa.SessionEntryJpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adaptateur d'infrastructure : implémente le Port SessionEntryRepository.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class PostgresSessionEntryRepository implements SessionEntryRepository {
|
||||||
|
|
||||||
|
private final SessionEntryJpaRepository jpaRepository;
|
||||||
|
|
||||||
|
public PostgresSessionEntryRepository(SessionEntryJpaRepository jpaRepository) {
|
||||||
|
this.jpaRepository = jpaRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SessionEntry save(SessionEntry entry) {
|
||||||
|
SessionEntryJpaEntity saved = jpaRepository.save(toJpaEntity(entry));
|
||||||
|
return toDomain(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<SessionEntry> findById(String id) {
|
||||||
|
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<SessionEntry> findBySessionId(String sessionId) {
|
||||||
|
return jpaRepository.findBySessionIdOrderByOccurredAtAsc(sessionId).stream()
|
||||||
|
.map(this::toDomain)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteById(String id) {
|
||||||
|
jpaRepository.deleteById(Long.parseLong(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code @Transactional} requis : Spring Data exige une transaction pour les deleteByXxx dérivés. */
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void deleteBySessionId(String sessionId) {
|
||||||
|
jpaRepository.deleteBySessionId(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean existsById(String id) {
|
||||||
|
return jpaRepository.existsById(Long.parseLong(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private SessionEntry toDomain(SessionEntryJpaEntity jpa) {
|
||||||
|
return SessionEntry.builder()
|
||||||
|
.id(jpa.getId().toString())
|
||||||
|
.sessionId(jpa.getSessionId())
|
||||||
|
.type(jpa.getType())
|
||||||
|
.content(jpa.getContent())
|
||||||
|
.occurredAt(jpa.getOccurredAt())
|
||||||
|
.createdAt(jpa.getCreatedAt())
|
||||||
|
.updatedAt(jpa.getUpdatedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private SessionEntryJpaEntity toJpaEntity(SessionEntry entry) {
|
||||||
|
Long id = entry.getId() != null ? Long.parseLong(entry.getId()) : null;
|
||||||
|
return SessionEntryJpaEntity.builder()
|
||||||
|
.id(id)
|
||||||
|
.sessionId(entry.getSessionId())
|
||||||
|
.type(entry.getType())
|
||||||
|
.content(entry.getContent())
|
||||||
|
.occurredAt(entry.getOccurredAt())
|
||||||
|
.createdAt(entry.getCreatedAt())
|
||||||
|
.updatedAt(entry.getUpdatedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package com.loremind.infrastructure.persistence.postgres;
|
||||||
|
|
||||||
|
import com.loremind.domain.playcontext.Session;
|
||||||
|
import com.loremind.domain.playcontext.ports.SessionRepository;
|
||||||
|
import com.loremind.infrastructure.persistence.entity.SessionJpaEntity;
|
||||||
|
import com.loremind.infrastructure.persistence.jpa.SessionJpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adaptateur d'infrastructure qui implémente le Port SessionRepository.
|
||||||
|
* Convertit Session (domaine pur) ↔ SessionJpaEntity (persistance).
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class PostgresSessionRepository implements SessionRepository {
|
||||||
|
|
||||||
|
private final SessionJpaRepository jpaRepository;
|
||||||
|
|
||||||
|
public PostgresSessionRepository(SessionJpaRepository jpaRepository) {
|
||||||
|
this.jpaRepository = jpaRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Session save(Session session) {
|
||||||
|
SessionJpaEntity saved = jpaRepository.save(toJpaEntity(session));
|
||||||
|
return toDomain(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<Session> findById(String id) {
|
||||||
|
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Session> findAll() {
|
||||||
|
return jpaRepository.findAll().stream()
|
||||||
|
.map(this::toDomain)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Session> findByCampaignId(String campaignId) {
|
||||||
|
return jpaRepository.findByCampaignIdOrderByStartedAtDesc(campaignId).stream()
|
||||||
|
.map(this::toDomain)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<Session> findActive() {
|
||||||
|
return jpaRepository.findFirstByEndedAtIsNull().map(this::toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteById(String id) {
|
||||||
|
jpaRepository.deleteById(Long.parseLong(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean existsById(String id) {
|
||||||
|
return jpaRepository.existsById(Long.parseLong(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Session toDomain(SessionJpaEntity jpa) {
|
||||||
|
return Session.builder()
|
||||||
|
.id(jpa.getId().toString())
|
||||||
|
.name(jpa.getName())
|
||||||
|
.campaignId(jpa.getCampaignId())
|
||||||
|
.startedAt(jpa.getStartedAt())
|
||||||
|
.endedAt(jpa.getEndedAt())
|
||||||
|
.createdAt(jpa.getCreatedAt())
|
||||||
|
.updatedAt(jpa.getUpdatedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private SessionJpaEntity toJpaEntity(Session session) {
|
||||||
|
Long id = session.getId() != null ? Long.parseLong(session.getId()) : null;
|
||||||
|
return SessionJpaEntity.builder()
|
||||||
|
.id(id)
|
||||||
|
.name(session.getName())
|
||||||
|
.campaignId(session.getCampaignId())
|
||||||
|
.startedAt(session.getStartedAt())
|
||||||
|
.endedAt(session.getEndedAt())
|
||||||
|
.createdAt(session.getCreatedAt())
|
||||||
|
.updatedAt(session.getUpdatedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package com.loremind.infrastructure.web;
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityNotFoundException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercepteur global d'exceptions pour TOUS les @RestController.
|
||||||
|
*
|
||||||
|
* <p>Role :
|
||||||
|
* <ul>
|
||||||
|
* <li>Logger systematiquement les exceptions non gerees (avec stack trace + path)
|
||||||
|
* — evite d'avoir a creuser dans les logs Docker apres coup.</li>
|
||||||
|
* <li>Renvoyer un JSON propre au client (`{error, type, ...}`) au lieu du 500 nu
|
||||||
|
* par defaut de Spring — utile pour debug cote frontend (visible directement
|
||||||
|
* dans la DevTools reseau).</li>
|
||||||
|
* <li>Mapper les exceptions courantes vers des status HTTP appropries
|
||||||
|
* (IllegalArgumentException -> 400, EntityNotFoundException -> 404).</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Important : ne court-circuite PAS les try/catch locaux des controllers
|
||||||
|
* (ex: LicenseController.install catche InstallException -> 400 lui-meme).
|
||||||
|
* Ce handler n'attrape QUE ce qui a echappe au catch local.
|
||||||
|
*/
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Violation d'invariant domaine (doublons, valeurs invalides, etc.) -> 400.
|
||||||
|
* Concentre ici la logique qui etait dupliquee dans GameSystemController.
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(IllegalArgumentException.class)
|
||||||
|
public ResponseEntity<Map<String, String>> handleIllegalArgument(IllegalArgumentException ex) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", safeMessage(ex)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entite JPA introuvable -> 404. */
|
||||||
|
@ExceptionHandler(EntityNotFoundException.class)
|
||||||
|
public ResponseEntity<Map<String, String>> handleNotFound(EntityNotFoundException ex) {
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", safeMessage(ex)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON malforme dans le body de la requete -> 400. */
|
||||||
|
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||||
|
public ResponseEntity<Map<String, String>> handleUnreadable(HttpMessageNotReadableException ex) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "Malformed request body"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validation @Valid echouee -> 400 avec liste des erreurs par champ. */
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
|
||||||
|
Map<String, String> fields = new LinkedHashMap<>();
|
||||||
|
ex.getBindingResult().getFieldErrors().forEach(e ->
|
||||||
|
fields.put(e.getField(), e.getDefaultMessage() != null ? e.getDefaultMessage() : "invalid"));
|
||||||
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
|
"error", "Validation failed",
|
||||||
|
"fields", fields
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback : tout ce qui n'a pas ete catche au-dessus -> 500, mais avec
|
||||||
|
* un log ERROR explicite (path + stack trace) et un body JSON debuggable
|
||||||
|
* cote client. C'est LE filet de securite.
|
||||||
|
*
|
||||||
|
* Note : on attrape Throwable (pas Exception) pour aussi capturer les
|
||||||
|
* Error (NoClassDefFoundError, OutOfMemoryError... — cf. incident Tink).
|
||||||
|
* On NE swallow PAS — on log AVANT de renvoyer une reponse.
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(Throwable.class)
|
||||||
|
public ResponseEntity<Map<String, String>> handleUnexpected(HttpServletRequest request, Throwable ex) {
|
||||||
|
log.error("Unhandled exception on {} {}", request.getMethod(), request.getRequestURI(), ex);
|
||||||
|
Map<String, String> body = new LinkedHashMap<>();
|
||||||
|
body.put("error", "Internal server error");
|
||||||
|
body.put("type", ex.getClass().getSimpleName());
|
||||||
|
String msg = safeMessage(ex);
|
||||||
|
if (!msg.isEmpty()) body.put("message", msg);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Evite les NPE quand getMessage() est null sur certaines exceptions. */
|
||||||
|
private static String safeMessage(Throwable ex) {
|
||||||
|
return ex.getMessage() != null ? ex.getMessage() : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,13 @@ package com.loremind.infrastructure.web.controller;
|
|||||||
|
|
||||||
import com.loremind.application.generationcontext.StreamChatForCampaignUseCase;
|
import com.loremind.application.generationcontext.StreamChatForCampaignUseCase;
|
||||||
import com.loremind.application.generationcontext.StreamChatForLoreUseCase;
|
import com.loremind.application.generationcontext.StreamChatForLoreUseCase;
|
||||||
|
import com.loremind.application.generationcontext.StreamChatForSessionUseCase;
|
||||||
import com.loremind.domain.generationcontext.ChatMessage;
|
import com.loremind.domain.generationcontext.ChatMessage;
|
||||||
import com.loremind.domain.generationcontext.ChatUsage;
|
import com.loremind.domain.generationcontext.ChatUsage;
|
||||||
import com.loremind.infrastructure.web.dto.generationcontext.ChatMessageDTO;
|
import com.loremind.infrastructure.web.dto.generationcontext.ChatMessageDTO;
|
||||||
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamCampaignRequestDTO;
|
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamCampaignRequestDTO;
|
||||||
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamRequestDTO;
|
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamRequestDTO;
|
||||||
|
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamSessionRequestDTO;
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.core.task.TaskExecutor;
|
import org.springframework.core.task.TaskExecutor;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
@@ -42,14 +44,17 @@ public class AiChatController {
|
|||||||
|
|
||||||
private final StreamChatForLoreUseCase streamChatForLoreUseCase;
|
private final StreamChatForLoreUseCase streamChatForLoreUseCase;
|
||||||
private final StreamChatForCampaignUseCase streamChatForCampaignUseCase;
|
private final StreamChatForCampaignUseCase streamChatForCampaignUseCase;
|
||||||
|
private final StreamChatForSessionUseCase streamChatForSessionUseCase;
|
||||||
private final TaskExecutor taskExecutor;
|
private final TaskExecutor taskExecutor;
|
||||||
|
|
||||||
public AiChatController(
|
public AiChatController(
|
||||||
StreamChatForLoreUseCase streamChatForLoreUseCase,
|
StreamChatForLoreUseCase streamChatForLoreUseCase,
|
||||||
StreamChatForCampaignUseCase streamChatForCampaignUseCase,
|
StreamChatForCampaignUseCase streamChatForCampaignUseCase,
|
||||||
|
StreamChatForSessionUseCase streamChatForSessionUseCase,
|
||||||
@Qualifier("applicationTaskExecutor") TaskExecutor taskExecutor) {
|
@Qualifier("applicationTaskExecutor") TaskExecutor taskExecutor) {
|
||||||
this.streamChatForLoreUseCase = streamChatForLoreUseCase;
|
this.streamChatForLoreUseCase = streamChatForLoreUseCase;
|
||||||
this.streamChatForCampaignUseCase = streamChatForCampaignUseCase;
|
this.streamChatForCampaignUseCase = streamChatForCampaignUseCase;
|
||||||
|
this.streamChatForSessionUseCase = streamChatForSessionUseCase;
|
||||||
this.taskExecutor = taskExecutor;
|
this.taskExecutor = taskExecutor;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +79,19 @@ public class AiChatController {
|
|||||||
return emitter;
|
return emitter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chat IA ancré sur une Session de jeu : récupère automatiquement la
|
||||||
|
* Campagne / Lore / GameSystem associés + injecte le journal horodaté.
|
||||||
|
*/
|
||||||
|
@PostMapping(value = "/chat/stream-session", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||||
|
public SseEmitter chatStreamSession(@RequestBody ChatStreamSessionRequestDTO body) {
|
||||||
|
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
|
||||||
|
List<ChatMessage> messages = toDomainMessages(body.getMessages());
|
||||||
|
|
||||||
|
taskExecutor.execute(() -> runSessionStreaming(emitter, body.getSessionId(), messages));
|
||||||
|
return emitter;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Exécution du streaming dans un thread dédié ------------------------
|
// --- Exécution du streaming dans un thread dédié ------------------------
|
||||||
|
|
||||||
private void runLoreStreaming(
|
private void runLoreStreaming(
|
||||||
@@ -111,6 +129,22 @@ public class AiChatController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void runSessionStreaming(
|
||||||
|
SseEmitter emitter,
|
||||||
|
String sessionId,
|
||||||
|
List<ChatMessage> messages) {
|
||||||
|
try {
|
||||||
|
streamChatForSessionUseCase.execute(
|
||||||
|
sessionId, messages,
|
||||||
|
usage -> sendUsage(emitter, usage),
|
||||||
|
token -> sendToken(emitter, token),
|
||||||
|
() -> complete(emitter),
|
||||||
|
error -> fail(emitter, error));
|
||||||
|
} catch (Exception e) {
|
||||||
|
fail(emitter, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Helpers SSE (un seul point d'écriture par type d'événement) --------
|
// --- Helpers SSE (un seul point d'écriture par type d'événement) --------
|
||||||
|
|
||||||
private void sendUsage(SseEmitter emitter, ChatUsage usage) {
|
private void sendUsage(SseEmitter emitter, ChatUsage usage) {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
|
|||||||
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||||
import com.loremind.infrastructure.web.mapper.GameSystemMapper;
|
import com.loremind.infrastructure.web.mapper.GameSystemMapper;
|
||||||
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
|
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@@ -72,12 +71,6 @@ public class GameSystemController {
|
|||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mappe les violations d'invariants domaine (doublons de champs, etc.) en 400. */
|
|
||||||
@ExceptionHandler(IllegalArgumentException.class)
|
|
||||||
public ResponseEntity<String> onIllegalArgument(IllegalArgumentException ex) {
|
|
||||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ex.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
private GameSystemService.GameSystemData toData(GameSystemDTO dto) {
|
private GameSystemService.GameSystemData toData(GameSystemDTO dto) {
|
||||||
return new GameSystemService.GameSystemData(
|
return new GameSystemService.GameSystemData(
|
||||||
dto.getName(),
|
dto.getName(),
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
package com.loremind.infrastructure.web.controller;
|
package com.loremind.infrastructure.web.controller;
|
||||||
|
|
||||||
|
import com.loremind.application.licensing.ChannelSwitcherService;
|
||||||
import com.loremind.application.licensing.LicenseService;
|
import com.loremind.application.licensing.LicenseService;
|
||||||
import com.loremind.application.licensing.LicenseService.InstallException;
|
import com.loremind.application.licensing.LicenseService.InstallException;
|
||||||
import com.loremind.domain.licensing.LicenseSnapshot;
|
import com.loremind.domain.licensing.LicenseSnapshot;
|
||||||
|
import com.loremind.infrastructure.web.dto.licensing.ChannelStatusDTO;
|
||||||
import com.loremind.infrastructure.web.dto.licensing.LicenseStatusDTO;
|
import com.loremind.infrastructure.web.dto.licensing.LicenseStatusDTO;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,9 +30,11 @@ import java.util.Map;
|
|||||||
public class LicenseController {
|
public class LicenseController {
|
||||||
|
|
||||||
private final LicenseService licenseService;
|
private final LicenseService licenseService;
|
||||||
|
private final ChannelSwitcherService channelSwitcher;
|
||||||
|
|
||||||
public LicenseController(LicenseService licenseService) {
|
public LicenseController(LicenseService licenseService, ChannelSwitcherService channelSwitcher) {
|
||||||
this.licenseService = licenseService;
|
this.licenseService = licenseService;
|
||||||
|
this.channelSwitcher = channelSwitcher;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -82,6 +88,68 @@ public class LicenseController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Bascule de canal (stable <-> beta) via sidecar switcher ────────────
|
||||||
|
//
|
||||||
|
// Le flux :
|
||||||
|
// 1. UI POST /api/license/channel/switch { channel: "beta" }
|
||||||
|
// 2. Core valide la licence (refus si target=beta sans Patreon actif)
|
||||||
|
// 3. Core depose une commande dans le volume partage
|
||||||
|
// 4. Sidecar `switcher` la traite (sed .env, docker compose up -d)
|
||||||
|
// 5. UI poll GET /api/license/channel pour suivre le status
|
||||||
|
|
||||||
|
/** Etat courant : canal actuel + dispo du sidecar + dernier resultat. */
|
||||||
|
@GetMapping("/channel")
|
||||||
|
public ChannelStatusDTO getChannel() {
|
||||||
|
return ChannelStatusDTO.from(channelSwitcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Declenche un switch de canal. Renvoie l'ID de la commande pour le polling. */
|
||||||
|
@PostMapping("/channel/switch")
|
||||||
|
public ResponseEntity<?> switchChannel(@RequestBody ChannelSwitchRequest request) {
|
||||||
|
if (request == null || request.channel() == null) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "missing channel"));
|
||||||
|
}
|
||||||
|
|
||||||
|
ChannelSwitcherService.Channel target;
|
||||||
|
try {
|
||||||
|
target = ChannelSwitcherService.Channel.valueOf(request.channel().toUpperCase(Locale.ROOT));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
|
"error", "invalid channel (allowed: stable, beta)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garde : pas de switch vers beta sans licence Patreon valide.
|
||||||
|
// Le switcher ferait le boulot quoi qu'il arrive (il valide juste le
|
||||||
|
// format), donc c'est ici qu'on doit refuser cote metier.
|
||||||
|
// VALID + GRACE autorisent l'acces beta (cf. javadoc de LicenseStatus).
|
||||||
|
if (target == ChannelSwitcherService.Channel.BETA) {
|
||||||
|
LicenseSnapshot snap = licenseService.getCurrentSnapshot();
|
||||||
|
com.loremind.domain.licensing.LicenseStatus s = (snap != null) ? snap.status() : null;
|
||||||
|
boolean allowed = s == com.loremind.domain.licensing.LicenseStatus.VALID
|
||||||
|
|| s == com.loremind.domain.licensing.LicenseStatus.GRACE;
|
||||||
|
if (!allowed) {
|
||||||
|
return ResponseEntity.status(403).body(Map.of(
|
||||||
|
"error", "Aucune licence Patreon active — impossible de basculer sur le canal beta."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!channelSwitcher.isSwitcherAvailable()) {
|
||||||
|
return ResponseEntity.status(503).body(Map.of(
|
||||||
|
"error", "Sidecar switcher non disponible (mise a jour requise du docker-compose.yml)."));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String id = channelSwitcher.requestSwitch(target);
|
||||||
|
return ResponseEntity.accepted().body(Map.of(
|
||||||
|
"id", id,
|
||||||
|
"channel", target.name().toLowerCase(Locale.ROOT)));
|
||||||
|
} catch (IOException e) {
|
||||||
|
return ResponseEntity.status(500).body(Map.of(
|
||||||
|
"error", "Impossible d'ecrire la commande de switch: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public record InstallRequest(String jwt) {}
|
public record InstallRequest(String jwt) {}
|
||||||
public record BetaChannelRequest(boolean enabled) {}
|
public record BetaChannelRequest(boolean enabled) {}
|
||||||
|
public record ChannelSwitchRequest(String channel) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.loremind.infrastructure.web.controller;
|
||||||
|
|
||||||
|
import com.loremind.application.playcontext.SessionService;
|
||||||
|
import com.loremind.domain.playcontext.Session;
|
||||||
|
import com.loremind.infrastructure.web.dto.playcontext.SessionDTO;
|
||||||
|
import com.loremind.infrastructure.web.mapper.SessionMapper;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST Controller pour le Play Context.
|
||||||
|
* Adaptateur d'infrastructure qui expose l'API REST des Sessions.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/sessions")
|
||||||
|
public class SessionController {
|
||||||
|
|
||||||
|
private final SessionService sessionService;
|
||||||
|
private final SessionMapper sessionMapper;
|
||||||
|
|
||||||
|
public SessionController(SessionService sessionService, SessionMapper sessionMapper) {
|
||||||
|
this.sessionService = sessionService;
|
||||||
|
this.sessionMapper = sessionMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record StartSessionRequest(String campaignId) {}
|
||||||
|
|
||||||
|
public record RenameSessionRequest(String name) {}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<SessionDTO> startSession(@RequestBody StartSessionRequest request) {
|
||||||
|
Session session = sessionService.startSession(request.campaignId());
|
||||||
|
return ResponseEntity.ok(sessionMapper.toDTO(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/active")
|
||||||
|
public ResponseEntity<SessionDTO> getActiveSession() {
|
||||||
|
return sessionService.getActive()
|
||||||
|
.map(s -> ResponseEntity.ok(sessionMapper.toDTO(s)))
|
||||||
|
.orElse(ResponseEntity.noContent().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<List<SessionDTO>> getSessions(@RequestParam(value = "campaignId", required = false) String campaignId) {
|
||||||
|
List<Session> sessions = (campaignId == null || campaignId.isBlank())
|
||||||
|
? sessionService.getAll()
|
||||||
|
: sessionService.getByCampaignId(campaignId);
|
||||||
|
List<SessionDTO> dtos = sessions.stream()
|
||||||
|
.map(sessionMapper::toDTO)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
return ResponseEntity.ok(dtos);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<SessionDTO> getSessionById(@PathVariable String id) {
|
||||||
|
return sessionService.getById(id)
|
||||||
|
.map(s -> ResponseEntity.ok(sessionMapper.toDTO(s)))
|
||||||
|
.orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/end")
|
||||||
|
public ResponseEntity<SessionDTO> endSession(@PathVariable String id) {
|
||||||
|
Session ended = sessionService.endSession(id);
|
||||||
|
return ResponseEntity.ok(sessionMapper.toDTO(ended));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/{id}")
|
||||||
|
public ResponseEntity<SessionDTO> renameSession(@PathVariable String id,
|
||||||
|
@RequestBody RenameSessionRequest request) {
|
||||||
|
Session renamed = sessionService.renameSession(id, request.name());
|
||||||
|
return ResponseEntity.ok(sessionMapper.toDTO(renamed));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<Void> deleteSession(@PathVariable String id) {
|
||||||
|
sessionService.deleteSession(id);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.loremind.infrastructure.web.controller;
|
||||||
|
|
||||||
|
import com.loremind.application.playcontext.SessionEntryService;
|
||||||
|
import com.loremind.domain.playcontext.EntryType;
|
||||||
|
import com.loremind.domain.playcontext.SessionEntry;
|
||||||
|
import com.loremind.infrastructure.web.dto.playcontext.SessionEntryDTO;
|
||||||
|
import com.loremind.infrastructure.web.mapper.SessionEntryMapper;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST Controller pour les entrées de journal d'une Session.
|
||||||
|
* Endpoints imbriqués sous /api/sessions/{sessionId}/entries.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/sessions/{sessionId}/entries")
|
||||||
|
public class SessionEntryController {
|
||||||
|
|
||||||
|
private final SessionEntryService entryService;
|
||||||
|
private final SessionEntryMapper entryMapper;
|
||||||
|
|
||||||
|
public SessionEntryController(SessionEntryService entryService, SessionEntryMapper entryMapper) {
|
||||||
|
this.entryService = entryService;
|
||||||
|
this.entryMapper = entryMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record EntryRequest(EntryType type, String content, LocalDateTime occurredAt) {}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<List<SessionEntryDTO>> getEntries(@PathVariable String sessionId) {
|
||||||
|
List<SessionEntryDTO> dtos = entryService.getBySessionId(sessionId).stream()
|
||||||
|
.map(entryMapper::toDTO)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
return ResponseEntity.ok(dtos);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<SessionEntryDTO> createEntry(@PathVariable String sessionId,
|
||||||
|
@RequestBody EntryRequest request) {
|
||||||
|
SessionEntry created = entryService.createEntry(
|
||||||
|
sessionId,
|
||||||
|
new SessionEntryService.EntryData(request.type(), request.content(), request.occurredAt())
|
||||||
|
);
|
||||||
|
return ResponseEntity.ok(entryMapper.toDTO(created));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{entryId}")
|
||||||
|
public ResponseEntity<SessionEntryDTO> updateEntry(@PathVariable String sessionId,
|
||||||
|
@PathVariable String entryId,
|
||||||
|
@RequestBody EntryRequest request) {
|
||||||
|
SessionEntry updated = entryService.updateEntry(
|
||||||
|
entryId,
|
||||||
|
new SessionEntryService.EntryData(request.type(), request.content(), request.occurredAt())
|
||||||
|
);
|
||||||
|
return ResponseEntity.ok(entryMapper.toDTO(updated));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{entryId}")
|
||||||
|
public ResponseEntity<Void> deleteEntry(@PathVariable String sessionId,
|
||||||
|
@PathVariable String entryId) {
|
||||||
|
entryService.deleteEntry(entryId);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.loremind.infrastructure.web.dto.generationcontext;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO de requête pour le chat IA d'une Session de jeu.
|
||||||
|
* Le contexte (lore, campagne, gamesystem, journal) est dérivé du sessionId
|
||||||
|
* côté serveur — l'appelant n'a qu'à fournir l'id et les messages.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ChatStreamSessionRequestDTO {
|
||||||
|
private String sessionId;
|
||||||
|
private List<ChatMessageDTO> messages;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.loremind.infrastructure.web.dto.licensing;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import com.loremind.application.licensing.ChannelSwitcherService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Etat du canal courant + dernier resultat de switch.
|
||||||
|
*
|
||||||
|
* <p>{@code currentChannel} : detecte au demarrage de Core a partir du prefixe
|
||||||
|
* d'image. {@code switcherAvailable} : indique si le sidecar de switch est
|
||||||
|
* monte (V0.9+) ou si on est sur une vieille install qui doit encore passer
|
||||||
|
* par les instructions manuelles.
|
||||||
|
*
|
||||||
|
* <p>{@code lastSwitch} : null tant qu'aucun switch n'a ete tente sur cette
|
||||||
|
* instance. Sinon, contient le resultat du dernier appel (en cours / succes /
|
||||||
|
* erreur), utilise par l'UI pour suivre la progression apres clic.
|
||||||
|
*/
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public record ChannelStatusDTO(
|
||||||
|
String currentChannel,
|
||||||
|
boolean switcherAvailable,
|
||||||
|
ChannelSwitcherService.SwitchResult lastSwitch) {
|
||||||
|
|
||||||
|
public static ChannelStatusDTO from(ChannelSwitcherService service) {
|
||||||
|
return new ChannelStatusDTO(
|
||||||
|
service.getCurrentChannel().name().toLowerCase(),
|
||||||
|
service.isSwitcherAvailable(),
|
||||||
|
service.getLastResult());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.loremind.infrastructure.web.dto.playcontext;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO pour l'entité Session — objet de transfert de l'API REST.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class SessionDTO {
|
||||||
|
|
||||||
|
private String id;
|
||||||
|
private String name;
|
||||||
|
private String campaignId;
|
||||||
|
private LocalDateTime startedAt;
|
||||||
|
/** Null = session en cours. */
|
||||||
|
private LocalDateTime endedAt;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
private boolean active;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.loremind.infrastructure.web.dto.playcontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.playcontext.EntryType;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO d'une entrée de journal de session.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class SessionEntryDTO {
|
||||||
|
|
||||||
|
private String id;
|
||||||
|
private String sessionId;
|
||||||
|
private EntryType type;
|
||||||
|
private String content;
|
||||||
|
private LocalDateTime occurredAt;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.loremind.infrastructure.web.mapper;
|
||||||
|
|
||||||
|
import com.loremind.domain.playcontext.SessionEntry;
|
||||||
|
import com.loremind.infrastructure.web.dto.playcontext.SessionEntryDTO;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class SessionEntryMapper {
|
||||||
|
|
||||||
|
public SessionEntryDTO toDTO(SessionEntry entry) {
|
||||||
|
if (entry == null) return null;
|
||||||
|
SessionEntryDTO dto = new SessionEntryDTO();
|
||||||
|
dto.setId(entry.getId());
|
||||||
|
dto.setSessionId(entry.getSessionId());
|
||||||
|
dto.setType(entry.getType());
|
||||||
|
dto.setContent(entry.getContent());
|
||||||
|
dto.setOccurredAt(entry.getOccurredAt());
|
||||||
|
dto.setCreatedAt(entry.getCreatedAt());
|
||||||
|
dto.setUpdatedAt(entry.getUpdatedAt());
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.loremind.infrastructure.web.mapper;
|
||||||
|
|
||||||
|
import com.loremind.domain.playcontext.Session;
|
||||||
|
import com.loremind.infrastructure.web.dto.playcontext.SessionDTO;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapper Session (domaine) ↔ SessionDTO (transport REST).
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class SessionMapper {
|
||||||
|
|
||||||
|
public SessionDTO toDTO(Session session) {
|
||||||
|
if (session == null) return null;
|
||||||
|
SessionDTO dto = new SessionDTO();
|
||||||
|
dto.setId(session.getId());
|
||||||
|
dto.setName(session.getName());
|
||||||
|
dto.setCampaignId(session.getCampaignId());
|
||||||
|
dto.setStartedAt(session.getStartedAt());
|
||||||
|
dto.setEndedAt(session.getEndedAt());
|
||||||
|
dto.setCreatedAt(session.getCreatedAt());
|
||||||
|
dto.setUpdatedAt(session.getUpdatedAt());
|
||||||
|
dto.setActive(session.isActive());
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,11 +102,18 @@ services:
|
|||||||
LICENSING_RELAY_BASE_URL: ${LICENSING_RELAY_BASE_URL:-https://loremind-auth.igmlcreation.fr}
|
LICENSING_RELAY_BASE_URL: ${LICENSING_RELAY_BASE_URL:-https://loremind-auth.igmlcreation.fr}
|
||||||
# Chemin du docker config.json partage avec Watchtower
|
# Chemin du docker config.json partage avec Watchtower
|
||||||
LICENSING_DOCKER_CONFIG_PATH: /shared/docker/config.json
|
LICENSING_DOCKER_CONFIG_PATH: /shared/docker/config.json
|
||||||
|
# Chemin du repertoire partage avec le switcher (commande + resultat).
|
||||||
|
# Doit matcher le volume `switcher-data` monte ci-dessous.
|
||||||
|
SWITCHER_DATA_PATH: /shared/switcher
|
||||||
volumes:
|
volumes:
|
||||||
# Volume partage avec Watchtower : Core ecrit les credentials registry
|
# Volume partage avec Watchtower : Core ecrit les credentials registry
|
||||||
# GHCR (recus du relais) ici, Watchtower les utilise pour pull les images
|
# GHCR (recus du relais) ici, Watchtower les utilise pour pull les images
|
||||||
# privees du canal beta. Pas de creds = no-op.
|
# privees du canal beta. Pas de creds = no-op.
|
||||||
- docker-config:/shared/docker
|
- docker-config:/shared/docker
|
||||||
|
# Volume partage avec le switcher : Core ecrit une commande de switch
|
||||||
|
# de canal ici (command.json), le switcher la traite et y depose son
|
||||||
|
# resultat (result.json). Cf. service `switcher` ci-dessous.
|
||||||
|
- switcher-data:/shared/switcher
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# Ollama embarque (option par defaut pour les utilisateurs sans Ollama installe).
|
# Ollama embarque (option par defaut pour les utilisateurs sans Ollama installe).
|
||||||
@@ -167,6 +174,51 @@ services:
|
|||||||
- "${WEB_PORT:-8081}:80"
|
- "${WEB_PORT:-8081}:80"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Sidecar de bascule de canal (stable <-> beta).
|
||||||
|
#
|
||||||
|
# Pourquoi : la bascule entre canaux change le PREFIXE d'image (loremind- vs
|
||||||
|
# loremind-beta-), donc Watchtower seul ne peut pas la faire — il met a jour
|
||||||
|
# des images, pas leur reference. Ce sidecar fait le `sed .env` + le
|
||||||
|
# `docker compose pull/up -d` quand le Core depose une commande JSON.
|
||||||
|
#
|
||||||
|
# Securite : pas de port expose. La commande arrive via volume partage
|
||||||
|
# (`switcher-data`) que SEUL le Core ecrit. Le switcher valide strictement
|
||||||
|
# le contenu (channel ∈ {stable, beta}, rien d'autre) — pas de RCE via
|
||||||
|
# compromission du Core.
|
||||||
|
#
|
||||||
|
# L'image switcher est volontairement HORS de IMAGE_NAMESPACE : elle reste
|
||||||
|
# `igmlcreation/loremind-switcher` sur les deux canaux. Sinon le switcher
|
||||||
|
# se tuerait lui-meme pendant le `docker compose up -d` (race condition).
|
||||||
|
switcher:
|
||||||
|
image: ghcr.io/igmlcreation/loremind-switcher:${SWITCHER_TAG:-latest}
|
||||||
|
container_name: loremind-switcher
|
||||||
|
# PAS de label watchtower : la maj du switcher se fait via le canal
|
||||||
|
# stable uniquement, et hors du flow d'auto-update.
|
||||||
|
volumes:
|
||||||
|
# Socket Docker du host : permet de lancer docker compose pull/up.
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
# Repertoire compose du host (docker-compose.yml + .env) — RW pour
|
||||||
|
# pouvoir sed la ligne IMAGE_NAMESPACE.
|
||||||
|
- ${COMPOSE_PROJECT_DIR:-./}:/compose
|
||||||
|
# Volume partage avec le Core pour la commande + le resultat.
|
||||||
|
- switcher-data:/data
|
||||||
|
# Volume partage avec le Core + Watchtower : contient config.json avec
|
||||||
|
# les creds GHCR (ecrits par le Core a partir du token Patreon).
|
||||||
|
# Indispensable pour pull les images privees du canal beta.
|
||||||
|
- docker-config:/shared/docker
|
||||||
|
environment:
|
||||||
|
# Repertoire interne ou trouver docker-compose.yml et .env. Bind au
|
||||||
|
# volume ci-dessus (COMPOSE_PROJECT_DIR = repertoire d'install du host).
|
||||||
|
COMPOSE_DIR: /compose
|
||||||
|
# Nom de projet docker compose : fixe ici pour que le switcher cible
|
||||||
|
# le MEME stack que celui qui tourne (sinon il creerait un duplicate).
|
||||||
|
# Doit matcher le `name:` (en V2.x) ou le nom du dossier du host.
|
||||||
|
COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-loremind}
|
||||||
|
# Indique au CLI Docker du switcher ou trouver config.json (auth GHCR
|
||||||
|
# pour les images privees beta). Meme mecanisme que sur Watchtower.
|
||||||
|
DOCKER_CONFIG: /shared/docker
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
# Mises a jour automatiques des images core/brain/web.
|
# Mises a jour automatiques des images core/brain/web.
|
||||||
# Active uniquement si COMPOSE_PROFILES=autoupdate (gere par l'installeur).
|
# Active uniquement si COMPOSE_PROFILES=autoupdate (gere par l'installeur).
|
||||||
# Postgres et MinIO sont volontairement exclus (donnees persistantes,
|
# Postgres et MinIO sont volontairement exclus (donnees persistantes,
|
||||||
@@ -214,3 +266,5 @@ volumes:
|
|||||||
# Volume partage Core <-> Watchtower : config.json Docker pour
|
# Volume partage Core <-> Watchtower : config.json Docker pour
|
||||||
# l'authentification au registry prive GHCR (canal beta Patreon).
|
# l'authentification au registry prive GHCR (canal beta Patreon).
|
||||||
docker-config:
|
docker-config:
|
||||||
|
# Volume partage Core <-> Switcher : commande de bascule de canal + resultat.
|
||||||
|
switcher-data:
|
||||||
|
|||||||
26
switcher/Dockerfile
Normal file
26
switcher/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# LoreMind channel switcher — sidecar minimal qui orchestre les bascules
|
||||||
|
# stable <-> beta. Tourne en permanence en attente d'une commande deposee
|
||||||
|
# dans le volume partage par le Core.
|
||||||
|
#
|
||||||
|
# Image volontairement legere (Alpine + docker-cli + bash). Pas de port
|
||||||
|
# expose, pas de processus reseau : tout passe par fichiers + socket Docker.
|
||||||
|
FROM alpine:3.20
|
||||||
|
|
||||||
|
# docker-cli : pour parler au socket Docker du host
|
||||||
|
# docker-cli-compose : pour `docker compose pull/up`
|
||||||
|
# bash : pour les scripts (sh ne suffit pas, on utilise des features bash)
|
||||||
|
# jq : parsing JSON de la commande
|
||||||
|
# coreutils : pour `date -u --iso-8601=seconds`
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
docker-cli \
|
||||||
|
docker-cli-compose \
|
||||||
|
bash \
|
||||||
|
jq \
|
||||||
|
coreutils
|
||||||
|
|
||||||
|
WORKDIR /switcher
|
||||||
|
COPY watch.sh switch.sh ./
|
||||||
|
RUN chmod +x watch.sh switch.sh
|
||||||
|
|
||||||
|
# Tourne en permanence en mode polling.
|
||||||
|
ENTRYPOINT ["/switcher/watch.sh"]
|
||||||
66
switcher/README.md
Normal file
66
switcher/README.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# LoreMind channel switcher
|
||||||
|
|
||||||
|
Sidecar qui bascule LoreMind entre les canaux **stable** et **beta** depuis l'UI,
|
||||||
|
sans manipulation manuelle du `.env` ni de docker-compose.
|
||||||
|
|
||||||
|
## Principe
|
||||||
|
|
||||||
|
Le switcher est un container minimal (Alpine + docker-cli + bash) qui :
|
||||||
|
|
||||||
|
1. Watch un fichier `command.json` dans un volume partagé avec le Core
|
||||||
|
2. Quand une commande arrive :
|
||||||
|
- Valide le canal cible (`stable` | `beta`)
|
||||||
|
- Sed la ligne `IMAGE_NAMESPACE` du `.env` du host
|
||||||
|
- Lance `docker compose pull` puis `docker compose up -d` sur core/brain/web
|
||||||
|
3. Écrit son résultat dans `result.json` (le Core remonte ça à l'UI via polling)
|
||||||
|
|
||||||
|
## Sécurité
|
||||||
|
|
||||||
|
Le switcher a accès au socket Docker et au répertoire compose du host (RW),
|
||||||
|
donc beaucoup de pouvoir. Pour éviter qu'une compromission du Core devienne
|
||||||
|
un RCE sur l'hôte :
|
||||||
|
|
||||||
|
- Le Core n'a **pas** accès au socket Docker — il dépose une commande dans un
|
||||||
|
fichier, point.
|
||||||
|
- Le switcher **valide strictement** le contenu : `channel` doit valoir exactement
|
||||||
|
`stable` ou `beta` (case statement, pas de regex laxiste).
|
||||||
|
- Aucun port n'est exposé. La communication se fait uniquement via volume
|
||||||
|
partagé.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┐ ┌──────────────────┐ ┌──────────────┐ ┌────────────┐
|
||||||
|
│ User clique │ │ Core │ │ switcher │ │ Docker │
|
||||||
|
│ "Passer beta"│─▶│ écrit command.json│─▶│ sed .env │─▶│ daemon │
|
||||||
|
│ dans UI │ │ dans volume │ │ docker compose│ │ (recreate) │
|
||||||
|
└──────────────┘ └──────────────────┘ └──────────────┘ └────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ result.json │ ◄── Core poll
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Upgrade pour les installs existantes
|
||||||
|
|
||||||
|
Le sidecar est arrivé dans LoreMind 0.9.0. Pour les installs antérieures qui
|
||||||
|
ne l'ont pas dans leur `docker-compose.yml`, l'utilisateur doit faire une
|
||||||
|
**dernière** manipulation :
|
||||||
|
|
||||||
|
1. Récupérer le nouveau `docker-compose.yml` du repo
|
||||||
|
2. Lancer `docker compose pull && docker compose up -d`
|
||||||
|
|
||||||
|
Après ça, tous les switchs futurs se font depuis l'UI sans intervention CLI.
|
||||||
|
|
||||||
|
## Pourquoi le switcher n'est PAS dans `IMAGE_NAMESPACE`
|
||||||
|
|
||||||
|
L'image du switcher est codée en dur (`ghcr.io/igmlcreation/loremind-switcher`)
|
||||||
|
plutôt que d'utiliser `${IMAGE_NAMESPACE}`. Raison : pendant un switch, le
|
||||||
|
switcher exécute `docker compose up -d`. Si son propre image faisait partie
|
||||||
|
de `IMAGE_NAMESPACE`, le compose voudrait le recréer en même temps que
|
||||||
|
core/brain/web — et il se tuerait au milieu de sa propre commande. Race
|
||||||
|
condition fatale.
|
||||||
|
|
||||||
|
Pour la même raison, le `docker compose up -d` dans `switch.sh` cible
|
||||||
|
explicitement `core brain web --no-deps` — jamais le switcher lui-même.
|
||||||
123
switcher/switch.sh
Normal file
123
switcher/switch.sh
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# switch.sh — execute le switch de canal pour LoreMind.
|
||||||
|
#
|
||||||
|
# Usage interne (appele par watch.sh) :
|
||||||
|
# ./switch.sh stable
|
||||||
|
# ./switch.sh beta
|
||||||
|
#
|
||||||
|
# Ce que ca fait, dans l'ordre :
|
||||||
|
# 1. Valide l'argument (stable|beta uniquement, rien d'autre — defense
|
||||||
|
# contre command injection si le Core etait compromis)
|
||||||
|
# 2. Sed la ligne IMAGE_NAMESPACE= du .env du host pour basculer le prefixe
|
||||||
|
# 3. docker compose pull (recupere les nouvelles images du canal cible)
|
||||||
|
# 4. docker compose up -d (recree core/brain/web avec les nouvelles images)
|
||||||
|
#
|
||||||
|
# Le switcher LUI-MEME n'est PAS dans IMAGE_NAMESPACE — il survit au switch
|
||||||
|
# sans interruption (cf. docker-compose.yml).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CHANNEL="${1:-}"
|
||||||
|
|
||||||
|
# --- Validation stricte -----------------------------------------------------
|
||||||
|
# Aucune autre valeur acceptee. Pas d'echappement, pas de slash, rien.
|
||||||
|
# C'est le filet de securite si le JSON depose dans /data/command.json
|
||||||
|
# contenait un payload exotique (Core compromis = on ne laisse PAS
|
||||||
|
# executer du code arbitraire sur l'hote).
|
||||||
|
case "${CHANNEL}" in
|
||||||
|
stable|beta) ;;
|
||||||
|
*)
|
||||||
|
echo "Channel invalide: '${CHANNEL}' (attendu: stable|beta)" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# --- Configuration ---------------------------------------------------------
|
||||||
|
# Repertoire monte depuis l'hote contenant docker-compose.yml + .env
|
||||||
|
COMPOSE_DIR="${COMPOSE_DIR:-/compose}"
|
||||||
|
ENV_FILE="${COMPOSE_DIR}/.env"
|
||||||
|
|
||||||
|
if [[ ! -f "${ENV_FILE}" ]]; then
|
||||||
|
echo "Fichier .env introuvable dans ${COMPOSE_DIR}" >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
if [[ ! -f "${COMPOSE_DIR}/docker-compose.yml" ]]; then
|
||||||
|
echo "docker-compose.yml introuvable dans ${COMPOSE_DIR}" >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Detection du nom de projet compose ------------------------------------
|
||||||
|
# Pour eviter que le switcher cree un projet PARALLELE (cas ou COMPOSE_PROJECT_NAME
|
||||||
|
# ne correspond pas au nom du projet sous lequel les containers tournent),
|
||||||
|
# on lit le label compose du container core en cours d'execution.
|
||||||
|
# Ce label est ecrit par docker compose au moment du `up -d` initial — c'est
|
||||||
|
# la source de verite.
|
||||||
|
PROJECT_NAME=$(docker inspect loremind-core \
|
||||||
|
--format '{{ index .Config.Labels "com.docker.compose.project" }}' \
|
||||||
|
2>/dev/null || echo "")
|
||||||
|
if [[ -z "${PROJECT_NAME}" ]]; then
|
||||||
|
# Fallback : env var ou defaut. Ne devrait pas arriver en prod
|
||||||
|
# (loremind-core tourne forcement quand l'UI declenche un switch).
|
||||||
|
PROJECT_NAME="${COMPOSE_PROJECT_NAME:-loremind}"
|
||||||
|
echo "Warning: nom de projet auto-detecte impossible, fallback sur '${PROJECT_NAME}'" >&2
|
||||||
|
fi
|
||||||
|
export COMPOSE_PROJECT_NAME="${PROJECT_NAME}"
|
||||||
|
echo "→ Projet compose cible: ${PROJECT_NAME}"
|
||||||
|
|
||||||
|
# --- Mapping canal -> (namespace, tag) -------------------------------------
|
||||||
|
# Le slash final du namespace est important : concatene avec le suffixe image
|
||||||
|
# (core/brain/web) dans le docker-compose.yml.
|
||||||
|
# Cote tag : le workflow CI pousse :latest pour le canal stable, :beta pour
|
||||||
|
# le canal beta. Le switcher doit donc forcer ces deux variables ensemble.
|
||||||
|
case "${CHANNEL}" in
|
||||||
|
stable)
|
||||||
|
NAMESPACE="igmlcreation/loremind-"
|
||||||
|
TAG="latest"
|
||||||
|
;;
|
||||||
|
beta)
|
||||||
|
NAMESPACE="igmlcreation/loremind-beta-"
|
||||||
|
TAG="beta"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Helper : met a jour (ou ajoute) une variable key=value dans le .env.
|
||||||
|
update_env_var() {
|
||||||
|
local key="$1"
|
||||||
|
local value="$2"
|
||||||
|
if grep -q "^${key}=" "${ENV_FILE}"; then
|
||||||
|
# Sur Alpine, sed -i sans backup. Le pattern '/' dans la valeur impose
|
||||||
|
# un delimiter alternatif (|).
|
||||||
|
sed -i "s|^${key}=.*|${key}=${value}|" "${ENV_FILE}"
|
||||||
|
else
|
||||||
|
# Ligne absente → on l'ajoute en fin de fichier la premiere fois.
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "# Ajoute automatiquement par le switcher de canal LoreMind."
|
||||||
|
echo "${key}=${value}"
|
||||||
|
} >> "${ENV_FILE}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Etape 1 : sed le .env -------------------------------------------------
|
||||||
|
echo "→ Mise a jour de IMAGE_NAMESPACE + TAG dans .env (canal: ${CHANNEL})"
|
||||||
|
update_env_var "IMAGE_NAMESPACE" "${NAMESPACE}"
|
||||||
|
update_env_var "TAG" "${TAG}"
|
||||||
|
|
||||||
|
# --- Etape 2 : docker compose pull -----------------------------------------
|
||||||
|
echo "→ Pull des nouvelles images (${NAMESPACE}*)"
|
||||||
|
# --no-deps inutile ici : pull n'a pas de notion de deps.
|
||||||
|
# --policy missing eviterait de re-puller si deja la, mais on VEUT puller
|
||||||
|
# pour avoir la derniere version disponible — c'est le but du switch.
|
||||||
|
cd "${COMPOSE_DIR}"
|
||||||
|
docker compose pull core brain web
|
||||||
|
|
||||||
|
# --- Etape 3 : recreate les containers avec les nouvelles images -----------
|
||||||
|
# On cible explicitement core/brain/web — pas le switcher (qui s'auto-tuerait
|
||||||
|
# au milieu de la commande), pas postgres/minio (pas de changement d'image).
|
||||||
|
# --no-deps : ne pas re-recreer postgres/minio comme effet de bord.
|
||||||
|
echo "→ Recreation des containers avec les nouvelles images"
|
||||||
|
docker compose up -d --no-deps core brain web
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Switch vers le canal ${CHANNEL} termine avec succes."
|
||||||
|
echo "Containers core/brain/web recrees avec ${NAMESPACE}*:${TAG}."
|
||||||
88
switcher/watch.sh
Normal file
88
switcher/watch.sh
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# watch.sh — boucle principale du switcher.
|
||||||
|
#
|
||||||
|
# Surveille /data/command.json (depose par le Core via l'API HTTP) et lance
|
||||||
|
# switch.sh quand une nouvelle commande arrive. L'ID de la commande sert
|
||||||
|
# d'idempotence : on ne traite pas deux fois la meme requete.
|
||||||
|
#
|
||||||
|
# Le resultat est ecrit dans /data/result.json pour que le Core puisse le
|
||||||
|
# remonter a l'UI via son endpoint de status.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DATA_DIR="/data"
|
||||||
|
COMMAND_FILE="${DATA_DIR}/command.json"
|
||||||
|
RESULT_FILE="${DATA_DIR}/result.json"
|
||||||
|
LAST_PROCESSED_FILE="${DATA_DIR}/.last-processed-id"
|
||||||
|
|
||||||
|
mkdir -p "${DATA_DIR}"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[$(date -u --iso-8601=seconds)] $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ecrit un resultat JSON dans result.json — atomique via tmp + mv.
|
||||||
|
write_result() {
|
||||||
|
local status="$1" # "in-progress" | "success" | "error"
|
||||||
|
local channel="$2" # "stable" | "beta" | ""
|
||||||
|
local message="$3"
|
||||||
|
local id="$4"
|
||||||
|
|
||||||
|
local tmp
|
||||||
|
tmp="$(mktemp -p "${DATA_DIR}" result.XXXXXX)"
|
||||||
|
cat > "${tmp}" <<EOF
|
||||||
|
{
|
||||||
|
"id": "${id}",
|
||||||
|
"status": "${status}",
|
||||||
|
"channel": "${channel}",
|
||||||
|
"message": $(printf '%s' "${message}" | jq -Rs .),
|
||||||
|
"completedAt": "$(date -u --iso-8601=seconds)"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
mv "${tmp}" "${RESULT_FILE}"
|
||||||
|
}
|
||||||
|
|
||||||
|
log "LoreMind channel switcher started — watching ${COMMAND_FILE}"
|
||||||
|
|
||||||
|
# Boucle de polling. Intervalle court (1s) — la charge est negligeable
|
||||||
|
# (un test de fichier) et l'utilisateur attend une reaction rapide.
|
||||||
|
while true; do
|
||||||
|
if [[ -f "${COMMAND_FILE}" ]]; then
|
||||||
|
# Parse la commande. Tolere les JSON malformes : on ignore et on attend.
|
||||||
|
if ! id=$(jq -er '.id' "${COMMAND_FILE}" 2>/dev/null); then
|
||||||
|
sleep 1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Idempotence : skip si on a deja traite cet ID.
|
||||||
|
last_id=""
|
||||||
|
[[ -f "${LAST_PROCESSED_FILE}" ]] && last_id=$(cat "${LAST_PROCESSED_FILE}")
|
||||||
|
if [[ "${id}" == "${last_id}" ]]; then
|
||||||
|
sleep 1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
channel=$(jq -er '.channel' "${COMMAND_FILE}" 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
log "New command received: id=${id} channel=${channel}"
|
||||||
|
write_result "in-progress" "${channel}" "Switch en cours..." "${id}"
|
||||||
|
|
||||||
|
# Lance le switch. On capture stdout+stderr et le code de sortie.
|
||||||
|
if output=$(/switcher/switch.sh "${channel}" 2>&1); then
|
||||||
|
log "Switch SUCCESS for id=${id} channel=${channel}"
|
||||||
|
# Log la sortie sur plusieurs lignes pour faciliter le debug
|
||||||
|
# (ce qu'on voit en docker logs).
|
||||||
|
while IFS= read -r line; do log " | ${line}"; done <<< "${output}"
|
||||||
|
write_result "success" "${channel}" "${output}" "${id}"
|
||||||
|
else
|
||||||
|
rc=$?
|
||||||
|
log "Switch FAILED for id=${id} channel=${channel} rc=${rc}"
|
||||||
|
while IFS= read -r line; do log " | ${line}"; done <<< "${output}"
|
||||||
|
write_result "error" "${channel}" "${output}" "${id}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Marque l'ID comme traite — empeche les replays.
|
||||||
|
echo "${id}" > "${LAST_PROCESSED_FILE}"
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
@@ -355,3 +355,55 @@ export async function getTemplateById(
|
|||||||
expect(res.ok(), `GET /api/templates/${templateId} -> ${res.status()}`).toBeTruthy();
|
expect(res.ok(), `GET /api/templates/${templateId} -> ${res.status()}`).toBeTruthy();
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────── GameSystem ───────────────
|
||||||
|
|
||||||
|
export interface SeededGameSystem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedGameSystem(
|
||||||
|
request: APIRequestContext,
|
||||||
|
opts: { name?: string; description?: string; author?: string; rulesMarkdown?: string } = {},
|
||||||
|
): Promise<SeededGameSystem> {
|
||||||
|
const name = opts.name ?? `E2E GameSystem ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||||
|
const res = await request.post('/api/game-systems', {
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
description: opts.description ?? null,
|
||||||
|
author: opts.author ?? null,
|
||||||
|
rulesMarkdown: opts.rulesMarkdown ?? null,
|
||||||
|
characterTemplate: [],
|
||||||
|
npcTemplate: [],
|
||||||
|
isPublic: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.ok(), `POST /api/game-systems -> ${res.status()}`).toBeTruthy();
|
||||||
|
const gs = await res.json();
|
||||||
|
return { id: gs.id, name };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteGameSystem(
|
||||||
|
request: APIRequestContext,
|
||||||
|
id: string,
|
||||||
|
): Promise<void> {
|
||||||
|
// Best-effort : ignore 404 si déjà supprimé par le test (ex: delete spec).
|
||||||
|
await request.delete(`/api/game-systems/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGameSystemById(
|
||||||
|
request: APIRequestContext,
|
||||||
|
id: string,
|
||||||
|
): Promise<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
author: string | null;
|
||||||
|
rulesMarkdown: string | null;
|
||||||
|
isPublic: boolean;
|
||||||
|
}> {
|
||||||
|
const res = await request.get(`/api/game-systems/${id}`);
|
||||||
|
expect(res.ok(), `GET /api/game-systems/${id} -> ${res.status()}`).toBeTruthy();
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,10 +24,12 @@ test.describe('Arc delete', () => {
|
|||||||
page,
|
page,
|
||||||
request,
|
request,
|
||||||
}) => {
|
}) => {
|
||||||
page.on('dialog', (dialog) => dialog.accept());
|
|
||||||
|
|
||||||
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
|
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
|
||||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
|
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
|
||||||
|
|
||||||
@@ -36,10 +38,12 @@ test.describe('Arc delete', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('keeps the arc when confirm is dismissed', async ({ page, request }) => {
|
test('keeps the arc when confirm is dismissed', async ({ page, request }) => {
|
||||||
page.on('dialog', (dialog) => dialog.dismiss());
|
|
||||||
|
|
||||||
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
|
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
|
||||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole('button', { name: /^Annuler$/i }).click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/arcs/${arc.id}/edit$`));
|
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/arcs/${arc.id}/edit$`));
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ test.describe('Arc edit', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
|
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
|
||||||
|
// Attend que le formulaire soit prerempli par le ngOnInit (HTTP async) avant
|
||||||
|
// de fill — sinon le patchValue du load arrive APRES nos fills et ecrase
|
||||||
|
// les valeurs, le test echoue alors a la verif persisted.name.
|
||||||
|
await expect(page.getByLabel(/Titre de l'arc/i)).toHaveValue(arc.name);
|
||||||
|
|
||||||
await page.getByLabel(/Titre de l'arc/i).fill(newName);
|
await page.getByLabel(/Titre de l'arc/i).fill(newName);
|
||||||
await page.getByLabel(/Synopsis de l'arc/i).fill(values.description);
|
await page.getByLabel(/Synopsis de l'arc/i).fill(values.description);
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ test.describe('Campaign delete', () => {
|
|||||||
page,
|
page,
|
||||||
request,
|
request,
|
||||||
}) => {
|
}) => {
|
||||||
page.on('dialog', (dialog) => dialog.accept());
|
|
||||||
|
|
||||||
await page.goto(`/campaigns/${campaign.id}`);
|
await page.goto(`/campaigns/${campaign.id}`);
|
||||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/campaigns$/);
|
await expect(page).toHaveURL(/\/campaigns$/);
|
||||||
|
|
||||||
@@ -28,10 +30,12 @@ test.describe('Campaign delete', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('keeps the campaign when confirm is dismissed', async ({ page, request }) => {
|
test('keeps the campaign when confirm is dismissed', async ({ page, request }) => {
|
||||||
page.on('dialog', (dialog) => dialog.dismiss());
|
|
||||||
|
|
||||||
await page.goto(`/campaigns/${campaign.id}`);
|
await page.goto(`/campaigns/${campaign.id}`);
|
||||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole('button', { name: /^Annuler$/i }).click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
|
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
|
||||||
|
|
||||||
|
|||||||
@@ -17,20 +17,22 @@ test.describe('NPC creation', () => {
|
|||||||
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('creates an NPC and redirects back to the campaign', async ({ page, request }) => {
|
test('creates an NPC and redirects to the NPC detail page', async ({ page, request }) => {
|
||||||
|
// Note : depuis la refonte 2026-04-30 les fiches PNJ utilisent des champs
|
||||||
|
// templates dynamiques pilotes par le GameSystem (plus de markdownContent
|
||||||
|
// libre). La campagne seedee n'a pas de GameSystem donc on ne fill que le
|
||||||
|
// nom — c'est suffisant pour valider la creation + la redirection.
|
||||||
const npcName = `Borin le forgeron ${Date.now()}`;
|
const npcName = `Borin le forgeron ${Date.now()}`;
|
||||||
const markdown = '# Borin\n\n**Faction :** Clan Feuillefer\n\nNain barbu au regard perçant.';
|
|
||||||
|
|
||||||
await page.goto(`/campaigns/${campaign.id}/npcs/create`);
|
await page.goto(`/campaigns/${campaign.id}/npcs/create`);
|
||||||
await expect(page.getByRole('heading', { name: /Nouveau PNJ/i })).toBeVisible();
|
await expect(page.getByRole('heading', { name: /Nouveau PNJ/i })).toBeVisible();
|
||||||
|
|
||||||
await page.getByLabel(/Nom du PNJ/i).fill(npcName);
|
await page.getByLabel(/Nom du PNJ/i).fill(npcName);
|
||||||
await page.getByLabel(/Fiche \(markdown\)/i).fill(markdown);
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /^Créer$/i }).click();
|
await page.getByRole('button', { name: /^Créer$/i }).click();
|
||||||
|
|
||||||
// Retour à la page campagne après création
|
// Redirection vers la fiche du PNJ après création
|
||||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
|
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/npcs/\\d+$`));
|
||||||
|
|
||||||
// Persistance vérifiée via API
|
// Persistance vérifiée via API
|
||||||
const npcs = await getNpcsByCampaign(request, campaign.id);
|
const npcs = await getNpcsByCampaign(request, campaign.id);
|
||||||
@@ -58,7 +60,7 @@ test.describe('NPC creation', () => {
|
|||||||
await page.getByLabel(/Nom du PNJ/i).fill(npcName);
|
await page.getByLabel(/Nom du PNJ/i).fill(npcName);
|
||||||
await page.getByRole('button', { name: /^Créer$/i }).click();
|
await page.getByRole('button', { name: /^Créer$/i }).click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
|
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/npcs/\\d+$`));
|
||||||
|
|
||||||
// Le nœud "PNJ" doit apparaître dans la sidebar avec le nouveau PNJ.
|
// Le nœud "PNJ" doit apparaître dans la sidebar avec le nouveau PNJ.
|
||||||
// On clique sur le nœud PNJ pour le déplier au cas où il serait fermé,
|
// On clique sur le nœud PNJ pour le déplier au cas où il serait fermé,
|
||||||
|
|||||||
@@ -14,19 +14,19 @@ test.describe('NPC edit', () => {
|
|||||||
|
|
||||||
test.beforeEach(async ({ request }) => {
|
test.beforeEach(async ({ request }) => {
|
||||||
campaign = await seedCampaign(request);
|
campaign = await seedCampaign(request);
|
||||||
npc = await seedNpc(request, {
|
npc = await seedNpc(request, { campaignId: campaign.id });
|
||||||
campaignId: campaign.id,
|
|
||||||
markdownContent: '# Initial\n\nFiche de départ.',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterEach(async ({ request }) => {
|
test.afterEach(async ({ request }) => {
|
||||||
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('edits name + markdown content and persists via API', async ({ page, request }) => {
|
test('edits name and persists via API', async ({ page, request }) => {
|
||||||
|
// Note : depuis la refonte 2026-04-30 les fiches PNJ utilisent des champs
|
||||||
|
// templates dynamiques pilotes par le GameSystem, plus le markdownContent
|
||||||
|
// libre. La campagne seedee n'a pas de GameSystem donc pas de champs
|
||||||
|
// dynamiques a tester ici — on se contente du nom (champ universel).
|
||||||
const newName = `${npc.name} (renommé)`;
|
const newName = `${npc.name} (renommé)`;
|
||||||
const newMarkdown = '# Borin réécrit\n\n**Statut :** Disparu\n\nDes traces dans la neige...';
|
|
||||||
|
|
||||||
await page.goto(`/campaigns/${campaign.id}/npcs/${npc.id}/edit`);
|
await page.goto(`/campaigns/${campaign.id}/npcs/${npc.id}/edit`);
|
||||||
|
|
||||||
@@ -34,7 +34,6 @@ test.describe('NPC edit', () => {
|
|||||||
await expect(page.getByLabel(/Nom du PNJ/i)).toHaveValue(npc.name);
|
await expect(page.getByLabel(/Nom du PNJ/i)).toHaveValue(npc.name);
|
||||||
|
|
||||||
await page.getByLabel(/Nom du PNJ/i).fill(newName);
|
await page.getByLabel(/Nom du PNJ/i).fill(newName);
|
||||||
await page.getByLabel(/Fiche \(markdown\)/i).fill(newMarkdown);
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /^Enregistrer$/i }).click();
|
await page.getByRole('button', { name: /^Enregistrer$/i }).click();
|
||||||
|
|
||||||
@@ -43,7 +42,6 @@ test.describe('NPC edit', () => {
|
|||||||
|
|
||||||
const persisted = await getNpcById(request, npc.id);
|
const persisted = await getNpcById(request, npc.id);
|
||||||
expect(persisted.name).toBe(newName);
|
expect(persisted.name).toBe(newName);
|
||||||
expect(persisted.markdownContent).toBe(newMarkdown);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('save button is disabled when name is cleared', async ({ page }) => {
|
test('save button is disabled when name is cleared', async ({ page }) => {
|
||||||
|
|||||||
74
web/e2e/tests/game-system/game-system-create.spec.ts
Normal file
74
web/e2e/tests/game-system/game-system-create.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { deleteGameSystem } from '../../fixtures/api';
|
||||||
|
|
||||||
|
test.describe('GameSystem creation', () => {
|
||||||
|
// Les game systems crees par les tests sont nettoyes via cet array — chaque
|
||||||
|
// test pousse les IDs qu'il a crees pour qu'on les supprime en afterEach.
|
||||||
|
const createdIds: string[] = [];
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
while (createdIds.length) {
|
||||||
|
const id = createdIds.pop()!;
|
||||||
|
await deleteGameSystem(request, id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates a game system and redirects to the list', async ({ page, request }) => {
|
||||||
|
const gsName = `Système E2E ${Date.now()}`;
|
||||||
|
const description = 'Système créé par les tests automatisés.';
|
||||||
|
const author = 'Playwright';
|
||||||
|
|
||||||
|
await page.goto('/game-systems');
|
||||||
|
await expect(page.getByRole('heading', { name: /Systèmes de JDR/i })).toBeVisible();
|
||||||
|
|
||||||
|
// Carte "Nouveau système" → ouvre l'editeur en mode creation.
|
||||||
|
await page.locator('.gs-card.card-new').click();
|
||||||
|
await expect(page).toHaveURL(/\/game-systems\/create$/);
|
||||||
|
await expect(page.getByRole('heading', { name: /Nouveau système de JDR/i })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel(/^Nom/i).fill(gsName);
|
||||||
|
await page.getByLabel(/Description courte/i).fill(description);
|
||||||
|
await page.getByLabel(/Auteur/i).fill(author);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^Créer$/i }).click();
|
||||||
|
|
||||||
|
// Redirection vers la liste apres creation.
|
||||||
|
await expect(page).toHaveURL(/\/game-systems$/);
|
||||||
|
// Et la carte du nouveau systeme est visible dans la grille.
|
||||||
|
await expect(page.locator('.gs-card', { hasText: gsName })).toBeVisible();
|
||||||
|
|
||||||
|
// Verification API : le systeme est bien persistant.
|
||||||
|
const all = await request.get('/api/game-systems').then((r) => r.json());
|
||||||
|
const created = all.find((gs: { id: string; name: string }) => gs.name === gsName);
|
||||||
|
expect(created).toBeDefined();
|
||||||
|
expect(created.author).toBe(author);
|
||||||
|
createdIds.push(created.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('submit button is disabled when name is empty', async ({ page }) => {
|
||||||
|
await page.goto('/game-systems/create');
|
||||||
|
|
||||||
|
const submit = page.getByRole('button', { name: /^Créer$/i });
|
||||||
|
await expect(submit).toBeDisabled();
|
||||||
|
|
||||||
|
await page.getByLabel(/^Nom/i).fill('Quelque chose');
|
||||||
|
await expect(submit).toBeEnabled();
|
||||||
|
|
||||||
|
await page.getByLabel(/^Nom/i).fill(' ');
|
||||||
|
await expect(submit).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancel returns to the list without creating', async ({ page, request }) => {
|
||||||
|
const abandoned = `Système abandonné ${Date.now()}`;
|
||||||
|
|
||||||
|
await page.goto('/game-systems/create');
|
||||||
|
await page.getByLabel(/^Nom/i).fill(abandoned);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^Annuler$/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/game-systems$/);
|
||||||
|
|
||||||
|
// Rien n'a ete cree cote API.
|
||||||
|
const all = await request.get('/api/game-systems').then((r) => r.json());
|
||||||
|
expect(all.find((gs: { name: string }) => gs.name === abandoned)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
61
web/e2e/tests/game-system/game-system-delete.spec.ts
Normal file
61
web/e2e/tests/game-system/game-system-delete.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
seedGameSystem,
|
||||||
|
deleteGameSystem,
|
||||||
|
type SeededGameSystem,
|
||||||
|
} from '../../fixtures/api';
|
||||||
|
|
||||||
|
test.describe('GameSystem delete', () => {
|
||||||
|
let gs: SeededGameSystem;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
gs = await seedGameSystem(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
// Best-effort cleanup — ne fait rien si deja supprime par le test.
|
||||||
|
if (gs?.id) await deleteGameSystem(request, gs.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deletes a game system after confirming and removes it from the list', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/game-systems');
|
||||||
|
|
||||||
|
const card = page.locator('.gs-card', { hasText: gs.name });
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
|
||||||
|
// Bouton corbeille dans le coin de la carte du systeme seede.
|
||||||
|
await card.locator('.icon-btn').click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await expect(dialog).toContainText(gs.name);
|
||||||
|
|
||||||
|
await dialog.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||||
|
|
||||||
|
// La carte disparait apres reload de la liste.
|
||||||
|
await expect(page.locator('.gs-card', { hasText: gs.name })).toHaveCount(0);
|
||||||
|
|
||||||
|
const res = await request.get(`/api/game-systems/${gs.id}`);
|
||||||
|
expect(res.status()).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the game system when cancel is clicked', async ({ page, request }) => {
|
||||||
|
await page.goto('/game-systems');
|
||||||
|
|
||||||
|
const card = page.locator('.gs-card', { hasText: gs.name });
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
await card.locator('.icon-btn').click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole('button', { name: /^Annuler$/i }).click();
|
||||||
|
|
||||||
|
// La carte est toujours la, le systeme est toujours en base.
|
||||||
|
await expect(page.locator('.gs-card', { hasText: gs.name })).toBeVisible();
|
||||||
|
const res = await request.get(`/api/game-systems/${gs.id}`);
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
68
web/e2e/tests/game-system/game-system-edit.spec.ts
Normal file
68
web/e2e/tests/game-system/game-system-edit.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
seedGameSystem,
|
||||||
|
deleteGameSystem,
|
||||||
|
getGameSystemById,
|
||||||
|
type SeededGameSystem,
|
||||||
|
} from '../../fixtures/api';
|
||||||
|
|
||||||
|
test.describe('GameSystem edit', () => {
|
||||||
|
let gs: SeededGameSystem;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
gs = await seedGameSystem(request, {
|
||||||
|
description: 'Description initiale.',
|
||||||
|
author: 'Auteur initial',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
if (gs?.id) await deleteGameSystem(request, gs.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('form is prefilled with the game system data', async ({ page }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: /Éditer le système/i })).toBeVisible();
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
await expect(page.getByLabel(/Description courte/i)).toHaveValue('Description initiale.');
|
||||||
|
await expect(page.getByLabel(/Auteur/i)).toHaveValue('Auteur initial');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('edits name and description and persists them to API', async ({ page, request }) => {
|
||||||
|
const newName = `${gs.name} renamed`;
|
||||||
|
const newDescription = 'Description mise à jour par le test.';
|
||||||
|
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
|
||||||
|
// Attente que le formulaire soit prerempli avant de fill — sinon le load
|
||||||
|
// async ecrase les valeurs filled (cf. bug arc-edit corrige).
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
await page.getByLabel(/^Nom/i).fill(newName);
|
||||||
|
await page.getByLabel(/Description courte/i).fill(newDescription);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^Enregistrer$/i }).click();
|
||||||
|
|
||||||
|
// Retour a la liste apres save.
|
||||||
|
await expect(page).toHaveURL(/\/game-systems$/);
|
||||||
|
|
||||||
|
const persisted = await getGameSystemById(request, gs.id);
|
||||||
|
expect(persisted.name).toBe(newName);
|
||||||
|
expect(persisted.description).toBe(newDescription);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('save button is disabled when name is cleared', async ({ page }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
const nameField = page.getByLabel(/^Nom/i);
|
||||||
|
const saveBtn = page.getByRole('button', { name: /^Enregistrer$/i });
|
||||||
|
|
||||||
|
await expect(saveBtn).toBeEnabled();
|
||||||
|
await nameField.fill('');
|
||||||
|
await expect(saveBtn).toBeDisabled();
|
||||||
|
await nameField.fill('OK');
|
||||||
|
await expect(saveBtn).toBeEnabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
111
web/e2e/tests/game-system/game-system-sections.spec.ts
Normal file
111
web/e2e/tests/game-system/game-system-sections.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
seedGameSystem,
|
||||||
|
deleteGameSystem,
|
||||||
|
type SeededGameSystem,
|
||||||
|
} from '../../fixtures/api';
|
||||||
|
|
||||||
|
test.describe('GameSystem rule sections editor', () => {
|
||||||
|
let gs: SeededGameSystem;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
// On part d'un GameSystem vide (pas de regles seedees) — chaque test gere
|
||||||
|
// ses propres ajouts pour eviter les couplages.
|
||||||
|
gs = await seedGameSystem(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
if (gs?.id) await deleteGameSystem(request, gs.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds a suggested section, fills it, and persists it', async ({ page, request }) => {
|
||||||
|
const sectionContent = 'Initiative à d20, action+bonus+mouvement, dégâts par dés.';
|
||||||
|
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
// Attendre le chargement du form (nom prerempli).
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
// Empty state visible tant qu'aucune section n'est ajoutee.
|
||||||
|
await expect(page.locator('.section-list .empty-hint')).toBeVisible();
|
||||||
|
|
||||||
|
// Ajout via la chip suggeree "Combat".
|
||||||
|
await page.locator('.add-row .chip', { hasText: 'Combat' }).click();
|
||||||
|
|
||||||
|
// Une section-card est apparue avec titre "Combat" prerempli + textarea visible.
|
||||||
|
const card = page.locator('.section-card').first();
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
await expect(card.locator('.section-title-input')).toHaveValue('Combat');
|
||||||
|
await card.locator('.section-content').fill(sectionContent);
|
||||||
|
|
||||||
|
// Save + retour a la liste.
|
||||||
|
await page.getByRole('button', { name: /^Enregistrer$/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/game-systems$/);
|
||||||
|
|
||||||
|
// Verification cote API : le markdown contient bien la section + son contenu.
|
||||||
|
const persisted = await request.get(`/api/game-systems/${gs.id}`).then((r) => r.json());
|
||||||
|
expect(persisted.rulesMarkdown).toContain('## Combat');
|
||||||
|
expect(persisted.rulesMarkdown).toContain(sectionContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disables a suggested chip after it has been used', async ({ page }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
const combatChip = page.locator('.add-row .chip', { hasText: 'Combat' });
|
||||||
|
await expect(combatChip).toBeEnabled();
|
||||||
|
|
||||||
|
await combatChip.click();
|
||||||
|
|
||||||
|
// Apres ajout, la chip "Combat" est desactivee (suggestion deja utilisee).
|
||||||
|
await expect(combatChip).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds a custom blank section via "Autre…" and lets the user name it', async ({ page }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
await page.locator('.add-row .chip-custom', { hasText: /Autre/i }).click();
|
||||||
|
|
||||||
|
// Section vierge ajoutee : titre vide, prete a remplir.
|
||||||
|
const card = page.locator('.section-card').first();
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
const titleInput = card.locator('.section-title-input');
|
||||||
|
await expect(titleInput).toHaveValue('');
|
||||||
|
await titleInput.fill('Sorts');
|
||||||
|
await expect(titleInput).toHaveValue('Sorts');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removes a section', async ({ page }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
await page.locator('.add-row .chip', { hasText: 'Combat' }).click();
|
||||||
|
await page.locator('.add-row .chip', { hasText: 'Classes' }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('.section-card')).toHaveCount(2);
|
||||||
|
|
||||||
|
// Supprime la premiere section (Combat).
|
||||||
|
await page.locator('.section-card').first().locator('.btn-remove').click();
|
||||||
|
await expect(page.locator('.section-card')).toHaveCount(1);
|
||||||
|
await expect(page.locator('.section-card').first().locator('.section-title-input')).toHaveValue('Classes');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collapses and expands a section', async ({ page }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
await page.locator('.add-row .chip', { hasText: 'Combat' }).click();
|
||||||
|
const card = page.locator('.section-card').first();
|
||||||
|
|
||||||
|
// Par defaut deployee : textarea visible.
|
||||||
|
await expect(card.locator('.section-content')).toBeVisible();
|
||||||
|
|
||||||
|
// Clic sur le bouton collapse → textarea masquee.
|
||||||
|
await card.locator('.btn-collapse').click();
|
||||||
|
await expect(card.locator('.section-content')).toHaveCount(0);
|
||||||
|
|
||||||
|
// Re-clic → re-deployee.
|
||||||
|
await card.locator('.btn-collapse').click();
|
||||||
|
await expect(card.locator('.section-content')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
151
web/e2e/tests/game-system/game-system-templates.spec.ts
Normal file
151
web/e2e/tests/game-system/game-system-templates.spec.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { test, expect, Page } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
seedGameSystem,
|
||||||
|
deleteGameSystem,
|
||||||
|
type SeededGameSystem,
|
||||||
|
} from '../../fixtures/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests du composant <app-template-fields-editor> dans le contexte GameSystem.
|
||||||
|
*
|
||||||
|
* Le composant est instancie DEUX fois sur la page d'edition d'un GameSystem
|
||||||
|
* (une fois pour PJ "characterTemplate", une fois pour PNJ "npcTemplate"), donc
|
||||||
|
* les selecteurs doivent etre scopes a l'instance ciblee. On utilise un helper
|
||||||
|
* `tfe(label)` qui renvoie le locator de l'editeur correspondant au titre.
|
||||||
|
*/
|
||||||
|
test.describe('GameSystem template fields editor (PJ / PNJ)', () => {
|
||||||
|
let gs: SeededGameSystem;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
gs = await seedGameSystem(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
if (gs?.id) await deleteGameSystem(request, gs.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Helper : retourne le locator de l'editeur de templates par son label. */
|
||||||
|
const tfe = (page: Page, label: 'PJ' | 'PNJ') =>
|
||||||
|
page.locator('.tfe').filter({ hasText: `Champs de la fiche ${label}` });
|
||||||
|
|
||||||
|
test('adds a suggested field to the PJ template and persists it', async ({ page, request }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
const pjEditor = tfe(page, 'PJ');
|
||||||
|
await expect(pjEditor).toBeVisible();
|
||||||
|
|
||||||
|
// Ajout de "Histoire" via la chip suggeree.
|
||||||
|
await pjEditor.locator('.tfe-add .chip', { hasText: 'Histoire' }).click();
|
||||||
|
|
||||||
|
// Une row apparait avec le nom prerempli.
|
||||||
|
const row = pjEditor.locator('.tfe-item').first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await expect(row.locator('.tfe-name')).toHaveValue('Histoire');
|
||||||
|
|
||||||
|
// Save → retour a la liste.
|
||||||
|
await page.getByRole('button', { name: /^Enregistrer$/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/game-systems$/);
|
||||||
|
|
||||||
|
// Verification API : le champ est bien dans characterTemplate.
|
||||||
|
const persisted = await request.get(`/api/game-systems/${gs.id}`).then((r) => r.json());
|
||||||
|
expect(persisted.characterTemplate).toEqual(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ name: 'Histoire' })]),
|
||||||
|
);
|
||||||
|
// npcTemplate non touche (toujours vide).
|
||||||
|
expect(persisted.npcTemplate ?? []).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds a custom NUMBER field via "Nombre" chip', async ({ page }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
const pjEditor = tfe(page, 'PJ');
|
||||||
|
await pjEditor.locator('.tfe-add .chip-custom', { hasText: 'Nombre' }).click();
|
||||||
|
|
||||||
|
const row = pjEditor.locator('.tfe-item').first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
// Champ vide, nom a remplir, type "NUMBER" pre-selectionne dans le select.
|
||||||
|
await expect(row.locator('.tfe-name')).toHaveValue('');
|
||||||
|
await expect(row.locator('.tfe-type')).toHaveValue('NUMBER');
|
||||||
|
|
||||||
|
await row.locator('.tfe-name').fill('Points de vie');
|
||||||
|
await expect(row.locator('.tfe-name')).toHaveValue('Points de vie');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PJ and PNJ editors are independent (adding to one does not affect the other)', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
await tfe(page, 'PJ').locator('.tfe-add .chip', { hasText: 'Histoire' }).click();
|
||||||
|
await tfe(page, 'PNJ').locator('.tfe-add .chip', { hasText: 'Motivation' }).click();
|
||||||
|
|
||||||
|
await expect(tfe(page, 'PJ').locator('.tfe-item')).toHaveCount(1);
|
||||||
|
await expect(tfe(page, 'PNJ').locator('.tfe-item')).toHaveCount(1);
|
||||||
|
await expect(tfe(page, 'PJ').locator('.tfe-item').first().locator('.tfe-name')).toHaveValue('Histoire');
|
||||||
|
await expect(tfe(page, 'PNJ').locator('.tfe-item').first().locator('.tfe-name')).toHaveValue('Motivation');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^Enregistrer$/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/game-systems$/);
|
||||||
|
|
||||||
|
const persisted = await request.get(`/api/game-systems/${gs.id}`).then((r) => r.json());
|
||||||
|
expect(persisted.characterTemplate).toEqual(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ name: 'Histoire' })]),
|
||||||
|
);
|
||||||
|
expect(persisted.npcTemplate).toEqual(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ name: 'Motivation' })]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removes a field from the template', async ({ page }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
const pjEditor = tfe(page, 'PJ');
|
||||||
|
await pjEditor.locator('.tfe-add .chip', { hasText: 'Histoire' }).click();
|
||||||
|
await pjEditor.locator('.tfe-add .chip', { hasText: 'Apparence' }).click();
|
||||||
|
|
||||||
|
await expect(pjEditor.locator('.tfe-item')).toHaveCount(2);
|
||||||
|
|
||||||
|
// Supprime le premier champ (Histoire) via son btn-remove.
|
||||||
|
await pjEditor.locator('.tfe-item').first().locator('.btn-remove').click();
|
||||||
|
await expect(pjEditor.locator('.tfe-item')).toHaveCount(1);
|
||||||
|
await expect(pjEditor.locator('.tfe-item').first().locator('.tfe-name')).toHaveValue('Apparence');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reorders fields with the up arrow button', async ({ page }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
const pjEditor = tfe(page, 'PJ');
|
||||||
|
await pjEditor.locator('.tfe-add .chip', { hasText: 'Histoire' }).click();
|
||||||
|
await pjEditor.locator('.tfe-add .chip', { hasText: 'Apparence' }).click();
|
||||||
|
|
||||||
|
// Ordre initial : Histoire, Apparence.
|
||||||
|
let rows = pjEditor.locator('.tfe-item');
|
||||||
|
await expect(rows.nth(0).locator('.tfe-name')).toHaveValue('Histoire');
|
||||||
|
await expect(rows.nth(1).locator('.tfe-name')).toHaveValue('Apparence');
|
||||||
|
|
||||||
|
// Monte Apparence d'un cran.
|
||||||
|
await rows.nth(1).locator('.btn-arrow').first().click();
|
||||||
|
|
||||||
|
rows = pjEditor.locator('.tfe-item');
|
||||||
|
await expect(rows.nth(0).locator('.tfe-name')).toHaveValue('Apparence');
|
||||||
|
await expect(rows.nth(1).locator('.tfe-name')).toHaveValue('Histoire');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disables a suggested chip after the field has been added', async ({ page }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
const pjEditor = tfe(page, 'PJ');
|
||||||
|
const histoireChip = pjEditor.locator('.tfe-add .chip', { hasText: 'Histoire' });
|
||||||
|
|
||||||
|
await expect(histoireChip).toBeEnabled();
|
||||||
|
await histoireChip.click();
|
||||||
|
await expect(histoireChip).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,32 +17,31 @@ test.describe('Lore delete', () => {
|
|||||||
page,
|
page,
|
||||||
request,
|
request,
|
||||||
}) => {
|
}) => {
|
||||||
let confirmMessage = '';
|
|
||||||
page.on('dialog', async (dialog) => {
|
|
||||||
confirmMessage = dialog.message();
|
|
||||||
await dialog.accept();
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto(`/lore/${seeded.id}`);
|
await page.goto(`/lore/${seeded.id}`);
|
||||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
|
||||||
|
|
||||||
// Attente du dialog et du retour sur la liste des lores.
|
const dialog = page.getByRole('dialog');
|
||||||
await expect(page).toHaveURL(/\/lore$/);
|
await expect(dialog).toBeVisible();
|
||||||
expect(confirmMessage).toContain(seeded.name);
|
await expect(dialog).toContainText(seeded.name);
|
||||||
// Lore contient un dossier seedé : le récapitulatif doit l'indiquer.
|
// Lore contient un dossier seedé : le récapitulatif doit l'indiquer.
|
||||||
expect(confirmMessage).toMatch(/1 dossier/i);
|
await expect(dialog).toContainText(/1 dossier/i);
|
||||||
|
|
||||||
|
await dialog.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||||
|
|
||||||
|
// Attente du retour sur la liste des lores.
|
||||||
|
await expect(page).toHaveURL(/\/lore$/);
|
||||||
|
|
||||||
const res = await request.get(`/api/lores/${seeded.id}`);
|
const res = await request.get(`/api/lores/${seeded.id}`);
|
||||||
expect(res.status()).toBe(404);
|
expect(res.status()).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('keeps the lore when the confirm is dismissed', async ({ page, request }) => {
|
test('keeps the lore when the confirm is dismissed', async ({ page, request }) => {
|
||||||
page.on('dialog', async (dialog) => {
|
|
||||||
await dialog.dismiss();
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto(`/lore/${seeded.id}`);
|
await page.goto(`/lore/${seeded.id}`);
|
||||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole('button', { name: /^Annuler$/i }).click();
|
||||||
|
|
||||||
// On reste sur le détail, le titre du lore est toujours visible.
|
// On reste sur le détail, le titre du lore est toujours visible.
|
||||||
await expect(page.locator('.detail-header h1')).toHaveText(seeded.name);
|
await expect(page.locator('.detail-header h1')).toHaveText(seeded.name);
|
||||||
|
|||||||
@@ -32,10 +32,12 @@ test.describe('Page delete', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('deletes the page after accepting confirm', async ({ page, request }) => {
|
test('deletes the page after accepting confirm', async ({ page, request }) => {
|
||||||
page.on('dialog', (dialog) => dialog.accept());
|
|
||||||
|
|
||||||
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
|
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
|
||||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||||
|
|
||||||
// Le composant redirige vers la racine du Lore après suppression.
|
// Le composant redirige vers la racine du Lore après suppression.
|
||||||
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
|
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
|
||||||
@@ -45,10 +47,12 @@ test.describe('Page delete', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('keeps the page when confirm is dismissed', async ({ page, request }) => {
|
test('keeps the page when confirm is dismissed', async ({ page, request }) => {
|
||||||
page.on('dialog', (dialog) => dialog.dismiss());
|
|
||||||
|
|
||||||
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
|
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
|
||||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole('button', { name: /^Annuler$/i }).click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/pages/${pageEntity.id}/edit$`));
|
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/pages/${pageEntity.id}/edit$`));
|
||||||
|
|
||||||
|
|||||||
@@ -25,11 +25,13 @@ test.describe('Template delete', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('deletes the template after accepting confirm', async ({ page, request }) => {
|
test('deletes the template after accepting confirm', async ({ page, request }) => {
|
||||||
page.on('dialog', (dialog) => dialog.accept());
|
|
||||||
|
|
||||||
await page.goto(`/lore/${seeded.id}/templates/${template.id}`);
|
await page.goto(`/lore/${seeded.id}/templates/${template.id}`);
|
||||||
await page.locator('.page-header .btn-danger').click();
|
await page.locator('.page-header .btn-danger').click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
|
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
|
||||||
|
|
||||||
const templates = await getTemplatesForLore(request, seeded.id);
|
const templates = await getTemplatesForLore(request, seeded.id);
|
||||||
@@ -37,11 +39,13 @@ test.describe('Template delete', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('keeps the template when confirm is dismissed', async ({ page, request }) => {
|
test('keeps the template when confirm is dismissed', async ({ page, request }) => {
|
||||||
page.on('dialog', (dialog) => dialog.dismiss());
|
|
||||||
|
|
||||||
await page.goto(`/lore/${seeded.id}/templates/${template.id}`);
|
await page.goto(`/lore/${seeded.id}/templates/${template.id}`);
|
||||||
await page.locator('.page-header .btn-danger').click();
|
await page.locator('.page-header .btn-danger').click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole('button', { name: /^Annuler$/i }).click();
|
||||||
|
|
||||||
// On reste sur l'écran d'édition (l'URL ne change pas).
|
// On reste sur l'écran d'édition (l'URL ne change pas).
|
||||||
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/templates/${template.id}$`));
|
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/templates/${template.id}$`));
|
||||||
|
|
||||||
|
|||||||
77
web/e2e/tests/secondary-sidebar-isolation.spec.ts
Normal file
77
web/e2e/tests/secondary-sidebar-isolation.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { seedLoreWithFolder, deleteLore, type SeededLore } from '../fixtures/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression : la secondary sidebar fuyait entre sections.
|
||||||
|
*
|
||||||
|
* Bug initial (2026-05-19) : on est sur /lore/:id (la sidebar affiche l'arbre
|
||||||
|
* du Lore), on clique sur "Campagne" dans la sidebar principale → on arrive
|
||||||
|
* sur /campaigns, MAIS la sidebar secondaire continuait d'afficher l'arbre
|
||||||
|
* du Lore precedent.
|
||||||
|
*
|
||||||
|
* Cause : les composants top-level (campaigns.component, lore.component,
|
||||||
|
* game-systems.component, settings.component) ne nettoyaient pas la sidebar
|
||||||
|
* heritee d'une section precedente. Fix : appel a layoutService.hide() dans
|
||||||
|
* leur ngOnInit.
|
||||||
|
*/
|
||||||
|
test.describe('Secondary sidebar — isolation entre sections', () => {
|
||||||
|
let seededLore: SeededLore;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
seededLore = await seedLoreWithFolder(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
if (seededLore?.id) await deleteLore(request, seededLore.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Lore detail → /campaigns : la sidebar secondaire disparait', async ({ page }) => {
|
||||||
|
// 1. Sur le detail d'un Lore, la sidebar secondaire est affichee avec
|
||||||
|
// le nom du Lore comme titre.
|
||||||
|
await page.goto(`/lore/${seededLore.id}`);
|
||||||
|
await expect(page.locator('app-secondary-sidebar')).toBeVisible();
|
||||||
|
await expect(page.locator('app-secondary-sidebar')).toContainText(seededLore.name);
|
||||||
|
|
||||||
|
// 2. Navigation vers la liste des campagnes (top-level).
|
||||||
|
await page.goto('/campaigns');
|
||||||
|
await expect(page.getByRole('heading', { name: /Vos Campagnes/i })).toBeVisible();
|
||||||
|
|
||||||
|
// 3. La sidebar secondaire ne doit PAS persister (sinon elle afficherait
|
||||||
|
// encore l'arbre du Lore precedent). Le *ngIf au niveau d'AppComponent
|
||||||
|
// la retire completement du DOM quand layoutService est en etat hidden.
|
||||||
|
await expect(page.locator('app-secondary-sidebar')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Lore detail → /game-systems : la sidebar secondaire disparait', async ({ page }) => {
|
||||||
|
await page.goto(`/lore/${seededLore.id}`);
|
||||||
|
await expect(page.locator('app-secondary-sidebar')).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto('/game-systems');
|
||||||
|
await expect(page.getByRole('heading', { name: /Systèmes de JDR/i })).toBeVisible();
|
||||||
|
await expect(page.locator('app-secondary-sidebar')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Lore detail → /settings : la sidebar secondaire disparait', async ({ page }) => {
|
||||||
|
await page.goto(`/lore/${seededLore.id}`);
|
||||||
|
await expect(page.locator('app-secondary-sidebar')).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto('/settings');
|
||||||
|
// Settings n'a pas de h1 forcement evident, on se base sur l'URL + l'absence
|
||||||
|
// de sidebar secondaire (objet du test).
|
||||||
|
await expect(page).toHaveURL(/\/settings$/);
|
||||||
|
await expect(page.locator('app-secondary-sidebar')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Lore detail → /lore (liste racine) : la sidebar secondaire disparait', async ({ page }) => {
|
||||||
|
// Sur le detail, la sidebar est visible
|
||||||
|
await page.goto(`/lore/${seededLore.id}`);
|
||||||
|
await expect(page.locator('app-secondary-sidebar')).toBeVisible();
|
||||||
|
|
||||||
|
// Retour a la liste racine du Lore
|
||||||
|
await page.goto('/lore');
|
||||||
|
await expect(page.getByRole('heading', { name: /Vos Univers/i })).toBeVisible();
|
||||||
|
|
||||||
|
// La sidebar ne doit plus apparaitre sur la liste racine.
|
||||||
|
await expect(page.locator('app-secondary-sidebar')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.8.3",
|
"version": "0.9.0-beta",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.8.3",
|
"version": "0.9.0-beta",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^17.0.0",
|
"@angular/animations": "^17.0.0",
|
||||||
"@angular/common": "^17.0.0",
|
"@angular/common": "^17.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.8.3",
|
"version": "0.9.0-beta",
|
||||||
"description": "LoreMind Frontend - Angular",
|
"description": "LoreMind Frontend - Angular",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
const baseURL = process.env['E2E_BASE_URL'] || 'http://localhost:8081';
|
// Par defaut on cible le serveur de dev Angular (ng serve) sur :4200 pour les
|
||||||
|
// runs locaux — c'est ce qu'on veut quand on bosse en TDD/dev sur le front.
|
||||||
|
// La CI (.gitea/workflows/e2e.yml) override avec `E2E_BASE_URL=http://web`
|
||||||
|
// pour cibler l'instance Docker dans le reseau du runner. Pour tester
|
||||||
|
// localement contre le container docker-compose, lancer :
|
||||||
|
// E2E_BASE_URL=http://localhost:8081 npm run e2e
|
||||||
|
const baseURL = process.env['E2E_BASE_URL'] || 'http://localhost:4200';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './e2e/tests',
|
testDir: './e2e/tests',
|
||||||
|
|||||||
@@ -18,3 +18,4 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<app-global-search></app-global-search>
|
<app-global-search></app-global-search>
|
||||||
|
<app-confirm-dialog-host></app-confirm-dialog-host>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { SidebarComponent } from './sidebar/sidebar.component';
|
|||||||
import { SecondarySidebarComponent } from './shared/secondary-sidebar/secondary-sidebar.component';
|
import { SecondarySidebarComponent } from './shared/secondary-sidebar/secondary-sidebar.component';
|
||||||
import { GlobalSearchComponent } from './shared/global-search/global-search.component';
|
import { GlobalSearchComponent } from './shared/global-search/global-search.component';
|
||||||
import { UpdateBannerComponent } from './shared/update-banner/update-banner.component';
|
import { UpdateBannerComponent } from './shared/update-banner/update-banner.component';
|
||||||
|
import { ConfirmDialogHostComponent } from './shared/confirm-dialog/confirm-dialog-host.component';
|
||||||
import { LayoutService } from './services/layout.service';
|
import { LayoutService } from './services/layout.service';
|
||||||
import { GlobalSearchService } from './services/global-search.service';
|
import { GlobalSearchService } from './services/global-search.service';
|
||||||
import { VersionCheckerService } from './services/version-checker.service';
|
import { VersionCheckerService } from './services/version-checker.service';
|
||||||
@@ -18,6 +19,7 @@ import { VersionCheckerService } from './services/version-checker.service';
|
|||||||
SecondarySidebarComponent,
|
SecondarySidebarComponent,
|
||||||
GlobalSearchComponent,
|
GlobalSearchComponent,
|
||||||
UpdateBannerComponent,
|
UpdateBannerComponent,
|
||||||
|
ConfirmDialogHostComponent,
|
||||||
AsyncPipe,
|
AsyncPipe,
|
||||||
NgIf,
|
NgIf,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export const routes: Routes = [
|
|||||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/create', loadComponent: () => import('./campaigns/scene/scene-create/scene-create.component').then(m => m.SceneCreateComponent) },
|
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/create', loadComponent: () => import('./campaigns/scene/scene-create/scene-create.component').then(m => m.SceneCreateComponent) },
|
||||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId', loadComponent: () => import('./campaigns/scene/scene-view/scene-view.component').then(m => m.SceneViewComponent) },
|
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId', loadComponent: () => import('./campaigns/scene/scene-view/scene-view.component').then(m => m.SceneViewComponent) },
|
||||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId/edit', loadComponent: () => import('./campaigns/scene/scene-edit/scene-edit.component').then(m => m.SceneEditComponent) },
|
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId/edit', loadComponent: () => import('./campaigns/scene/scene-edit/scene-edit.component').then(m => m.SceneEditComponent) },
|
||||||
|
{ path: 'sessions/:id', loadComponent: () => import('./sessions/session-detail/session-detail.component').then(m => m.SessionDetailComponent) },
|
||||||
{ path: 'game-systems', loadComponent: () => import('./game-systems/game-systems.component').then(m => m.GameSystemsComponent) },
|
{ 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/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: 'game-systems/:id/edit', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) },
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ import { LucideAngularModule, BookOpen } from 'lucide-angular';
|
|||||||
import { CampaignService } from '../../../services/campaign.service';
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
import { CharacterService } from '../../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { NpcService } from '../../../services/npc.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
import { LayoutService } from '../../../services/layout.service';
|
||||||
import { Campaign } from '../../../services/campaign.model';
|
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
|
||||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||||
|
|
||||||
@@ -62,21 +61,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
|
|||||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||||
this.existingArcCount = treeData.arcs.length;
|
this.existingArcCount = treeData.arcs.length;
|
||||||
|
|
||||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
|
||||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.layoutService.show({
|
|
||||||
title: campaign.name,
|
|
||||||
items: buildCampaignTree(this.campaignId, treeData),
|
|
||||||
footerLabel: 'Toutes les campagnes',
|
|
||||||
createActions: [
|
|
||||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
|
||||||
],
|
|
||||||
globalItems,
|
|
||||||
globalBackLabel: 'Toutes les campagnes',
|
|
||||||
globalBackRoute: '/campaigns'
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +74,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
|
|||||||
order: this.existingArcCount + 1,
|
order: this.existingArcCount + 1,
|
||||||
icon: this.selectedIcon
|
icon: this.selectedIcon
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', created.id, 'edit']),
|
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', created.id]),
|
||||||
error: () => console.error('Erreur lors de la création de l\'arc')
|
error: () => console.error('Erreur lors de la création de l\'arc')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -99,6 +84,9 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,16 +9,17 @@ import { CampaignService } from '../../../services/campaign.service';
|
|||||||
import { CharacterService } from '../../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { NpcService } from '../../../services/npc.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { PageService } from '../../../services/page.service';
|
import { PageService } from '../../../services/page.service';
|
||||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
import { LayoutService } from '../../../services/layout.service';
|
||||||
import { PageTitleService } from '../../../services/page-title.service';
|
import { PageTitleService } from '../../../services/page-title.service';
|
||||||
import { Campaign, Arc } from '../../../services/campaign.model';
|
import { Arc } from '../../../services/campaign.model';
|
||||||
import { Page } from '../../../services/page.model';
|
import { Page } from '../../../services/page.model';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||||
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
|
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
|
||||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||||
|
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran de détail/modification d'un Arc.
|
* Écran de détail/modification d'un Arc.
|
||||||
@@ -78,7 +79,8 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
|||||||
private npcService: NpcService,
|
private npcService: NpcService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService,
|
||||||
|
private confirmDialog: ConfirmDialogService
|
||||||
) {
|
) {
|
||||||
this.form = this.fb.group({
|
this.form = this.fb.group({
|
||||||
name: ['', Validators.required],
|
name: ['', Validators.required],
|
||||||
@@ -142,21 +144,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
|||||||
resolution: arc.resolution ?? ''
|
resolution: arc.resolution ?? ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
|
||||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.layoutService.show({
|
|
||||||
title: campaign.name,
|
|
||||||
items: buildCampaignTree(this.campaignId, treeData),
|
|
||||||
footerLabel: 'Toutes les campagnes',
|
|
||||||
createActions: [
|
|
||||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
|
||||||
],
|
|
||||||
globalItems,
|
|
||||||
globalBackLabel: 'Toutes les campagnes',
|
|
||||||
globalBackRoute: '/campaigns'
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,11 +171,19 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
delete(): void {
|
delete(): void {
|
||||||
if (!confirm(`Supprimer l'arc "${this.arc?.name}" ? Cette action est irréversible.`)) return;
|
this.confirmDialog.confirm({
|
||||||
|
title: 'Supprimer l\'arc',
|
||||||
|
message: `Supprimer l'arc "${this.arc?.name}" ?`,
|
||||||
|
details: ['Cette action est irréversible.'],
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
this.campaignService.deleteArc(this.arcId).subscribe({
|
this.campaignService.deleteArc(this.arcId).subscribe({
|
||||||
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
||||||
error: () => console.error('Erreur lors de la suppression')
|
error: () => console.error('Erreur lors de la suppression')
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel(): void {
|
cancel(): void {
|
||||||
@@ -195,6 +191,9 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import { CampaignService } from '../../../services/campaign.service';
|
|||||||
import { CharacterService } from '../../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { NpcService } from '../../../services/npc.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { PageService } from '../../../services/page.service';
|
import { PageService } from '../../../services/page.service';
|
||||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
import { LayoutService } from '../../../services/layout.service';
|
||||||
import { PageTitleService } from '../../../services/page-title.service';
|
import { PageTitleService } from '../../../services/page-title.service';
|
||||||
import { Campaign, Arc } from '../../../services/campaign.model';
|
import { Arc } from '../../../services/campaign.model';
|
||||||
import { Page } from '../../../services/page.model';
|
import { Page } from '../../../services/page.model';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||||
|
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran de consultation d'un Arc narratif (lecture seule).
|
* Écran de consultation d'un Arc narratif (lecture seule).
|
||||||
@@ -50,7 +51,8 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
|||||||
private npcService: NpcService,
|
private npcService: NpcService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService,
|
||||||
|
private confirmDialog: ConfirmDialogService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -83,20 +85,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
|||||||
this.availablePages = pages;
|
this.availablePages = pages;
|
||||||
this.pageTitleService.set(arc.name);
|
this.pageTitleService.set(arc.name);
|
||||||
|
|
||||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
|
||||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
|
||||||
}));
|
|
||||||
this.layoutService.show({
|
|
||||||
title: campaign.name,
|
|
||||||
items: buildCampaignTree(this.campaignId, treeData),
|
|
||||||
footerLabel: 'Toutes les campagnes',
|
|
||||||
createActions: [
|
|
||||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
|
||||||
],
|
|
||||||
globalItems,
|
|
||||||
globalBackLabel: 'Toutes les campagnes',
|
|
||||||
globalBackRoute: '/campaigns'
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,25 +111,34 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
|||||||
if (impact.chapters > 0) parts.push(`${impact.chapters} chapitre${impact.chapters > 1 ? 's' : ''}`);
|
if (impact.chapters > 0) parts.push(`${impact.chapters} chapitre${impact.chapters > 1 ? 's' : ''}`);
|
||||||
if (impact.scenes > 0) parts.push(`${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}`);
|
if (impact.scenes > 0) parts.push(`${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}`);
|
||||||
|
|
||||||
const lines = [`Supprimer l'arc "${arc.name}" ?`];
|
const details: string[] = [];
|
||||||
if (parts.length) {
|
if (parts.length) {
|
||||||
lines.push('');
|
details.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
|
||||||
lines.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
|
|
||||||
}
|
}
|
||||||
lines.push('');
|
details.push('Cette action est irréversible.');
|
||||||
lines.push('Cette action est irréversible.');
|
|
||||||
|
|
||||||
if (!confirm(lines.join('\n'))) return;
|
this.confirmDialog.confirm({
|
||||||
|
title: 'Supprimer l\'arc',
|
||||||
|
message: `Supprimer l'arc "${arc.name}" ?`,
|
||||||
|
details,
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
this.campaignService.deleteArc(arc.id!).subscribe({
|
this.campaignService.deleteArc(arc.id!).subscribe({
|
||||||
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
||||||
error: () => console.error('Erreur lors de la suppression de l\'arc')
|
error: () => console.error('Erreur lors de la suppression de l\'arc')
|
||||||
});
|
});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
error: () => console.error('Impossible de récupérer les dépendances de l\'arc')
|
error: () => console.error('Impossible de récupérer les dépendances de l\'arc')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { switchMap, map } from 'rxjs/operators';
|
|||||||
import { CampaignService } from '../services/campaign.service';
|
import { CampaignService } from '../services/campaign.service';
|
||||||
import { CharacterService } from '../services/character.service';
|
import { CharacterService } from '../services/character.service';
|
||||||
import { NpcService } from '../services/npc.service';
|
import { NpcService } from '../services/npc.service';
|
||||||
import { TreeItem } from '../services/layout.service';
|
import { TreeItem, SecondarySidebarConfig, GlobalItem } from '../services/layout.service';
|
||||||
import { Arc, Chapter, Scene } from '../services/campaign.model';
|
import { Arc, Chapter, Scene, Campaign } from '../services/campaign.model';
|
||||||
import { Character } from '../services/character.model';
|
import { Character } from '../services/character.model';
|
||||||
import { Npc } from '../services/npc.model';
|
import { Npc } from '../services/npc.model';
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
|||||||
const characterItems: TreeItem[] = sortedCharacters.map(ch => ({
|
const characterItems: TreeItem[] = sortedCharacters.map(ch => ({
|
||||||
id: `character-${ch.id}`,
|
id: `character-${ch.id}`,
|
||||||
label: ch.name,
|
label: ch.name,
|
||||||
route: `/campaigns/${campaignId}/characters/${ch.id}/edit`
|
route: `/campaigns/${campaignId}/characters/${ch.id}`
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const charactersNode: TreeItem = {
|
const charactersNode: TreeItem = {
|
||||||
@@ -107,7 +107,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
|||||||
const npcItems: TreeItem[] = sortedNpcs.map(n => ({
|
const npcItems: TreeItem[] = sortedNpcs.map(n => ({
|
||||||
id: `npc-${n.id}`,
|
id: `npc-${n.id}`,
|
||||||
label: n.name,
|
label: n.name,
|
||||||
route: `/campaigns/${campaignId}/npcs/${n.id}/edit`
|
route: `/campaigns/${campaignId}/npcs/${n.id}`
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const npcsNode: TreeItem = {
|
const npcsNode: TreeItem = {
|
||||||
@@ -172,3 +172,35 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
|||||||
|
|
||||||
return [...arcNodes, charactersNode, npcsNode];
|
return [...arcNodes, charactersNode, npcsNode];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit la SecondarySidebarConfig complete d'une campagne a partir des
|
||||||
|
* donnees deja chargees. A utiliser quand le composant fait deja un forkJoin
|
||||||
|
* pour ses propres donnees (arc-view, scene-edit, etc.) et a deja `campaign`,
|
||||||
|
* `allCampaigns` et `treeData` en main — evite de refaire les memes HTTP.
|
||||||
|
*
|
||||||
|
* Pour les composants qui n'ont pas d'autre fetch a faire (character-view,
|
||||||
|
* npc-view...), preferer CampaignSidebarService.show(campaignId) qui orchestre
|
||||||
|
* le forkJoin et appelle layoutService.show() en une seule ligne.
|
||||||
|
*/
|
||||||
|
export function buildCampaignSidebarConfig(
|
||||||
|
campaign: Campaign,
|
||||||
|
allCampaigns: Campaign[],
|
||||||
|
treeData: CampaignTreeData,
|
||||||
|
campaignId: string
|
||||||
|
): SecondarySidebarConfig {
|
||||||
|
const globalItems: GlobalItem[] = allCampaigns.map(c => ({
|
||||||
|
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
title: campaign.name,
|
||||||
|
items: buildCampaignTree(campaignId, treeData),
|
||||||
|
footerLabel: 'Toutes les campagnes',
|
||||||
|
createActions: [
|
||||||
|
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${campaignId}/arcs/create` }
|
||||||
|
],
|
||||||
|
globalItems,
|
||||||
|
globalBackLabel: 'Toutes les campagnes',
|
||||||
|
globalBackRoute: '/campaigns'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,14 +50,50 @@
|
|||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="campaign-game-system">Système de JDR</label>
|
<label for="campaign-game-system">Système de JDR</label>
|
||||||
<select id="campaign-game-system" formControlName="gameSystemId">
|
<select *ngIf="!creatingGameSystem" id="campaign-game-system" formControlName="gameSystemId">
|
||||||
<option value="">— Aucun (campagne générique) —</option>
|
<option value="">— Aucun (campagne générique) —</option>
|
||||||
<option *ngFor="let gs of availableGameSystems" [value]="gs.id">{{ gs.name }}</option>
|
<option *ngFor="let gs of availableGameSystems" [value]="gs.id">{{ gs.name }}</option>
|
||||||
|
<option [value]="CREATE_GAMESYSTEM_SENTINEL">+ Créer un nouveau système…</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!-- Mode creation inline : remplace temporairement le select. -->
|
||||||
|
<div *ngIf="creatingGameSystem" class="inline-create">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="newGameSystemName"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
placeholder="Nom du nouveau système (ex: D&D 5e, Nimble, Maison)"
|
||||||
|
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
|
||||||
|
(keydown.escape)="cancelCreateGameSystem()"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<div class="inline-create-actions">
|
||||||
|
<button type="button" class="btn-inline-primary"
|
||||||
|
[disabled]="!newGameSystemName.trim() || creatingGameSystemInFlight"
|
||||||
|
(click)="submitCreateGameSystem()">
|
||||||
|
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
|
||||||
|
Créer
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-inline-secondary" (click)="cancelCreateGameSystem()">
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
|
Création rapide — vous pourrez ajouter les règles, les templates de fiches PJ/PNJ
|
||||||
|
et le reste depuis la section "Systèmes" plus tard.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p *ngIf="!creatingGameSystem" class="hint">
|
||||||
Optionnel. Si défini, l'IA injectera les règles du système (classes, combat, lore...)
|
Optionnel. Si défini, l'IA injectera les règles du système (classes, combat, lore...)
|
||||||
dans ses suggestions pour respecter les mécaniques du JDR.
|
dans ses suggestions pour respecter les mécaniques du JDR.
|
||||||
</p>
|
</p>
|
||||||
|
<p *ngIf="!creatingGameSystem" class="hint hint-warning">
|
||||||
|
⚠️ Le système de jeu choisi détermine aussi le <strong>template des fiches de PJ et PNJ</strong>.
|
||||||
|
Le changer plus tard rendra les champs des fiches existantes invisibles
|
||||||
|
(les données restent stockées mais ne s'afficheront qu'en revenant à l'ancien système).
|
||||||
|
Choisissez bien dès le départ si possible.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-box">
|
<div class="info-box">
|
||||||
|
|||||||
@@ -87,6 +87,81 @@ form {
|
|||||||
input[type="number"] { width: 120px; }
|
input[type="number"] { width: 120px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-create {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.875rem;
|
||||||
|
background: #1a2233;
|
||||||
|
border: 1px solid #2d3748;
|
||||||
|
border-left: 3px solid #6c63ff;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
background: #1f2937;
|
||||||
|
border: 1px solid #374151;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.6rem 0.875rem;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
|
||||||
|
&::placeholder { color: #4b5563; }
|
||||||
|
&:focus { border-color: #6c63ff; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-create-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-inline-primary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.5rem 0.875rem;
|
||||||
|
background: #6c63ff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) { background: #5b52e0; }
|
||||||
|
&:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-inline-secondary {
|
||||||
|
padding: 0.5rem 0.875rem;
|
||||||
|
background: transparent;
|
||||||
|
color: #9ca3af;
|
||||||
|
border: 1px solid #374151;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
|
||||||
|
&:hover { background: #1f2937; color: white; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-warning {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
background: rgba(234, 179, 8, 0.08);
|
||||||
|
border-left: 3px solid #eab308;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
color: #fbbf24;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
strong { color: #fde68a; }
|
||||||
|
}
|
||||||
|
|
||||||
.info-box {
|
.info-box {
|
||||||
background: #1f2937;
|
background: #1f2937;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Component, EventEmitter, OnInit, Output } from '@angular/core';
|
import { Component, EventEmitter, OnInit, Output } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { LucideAngularModule, BookCopy, X } from 'lucide-angular';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { LucideAngularModule, BookCopy, X, Plus, Check } from 'lucide-angular';
|
||||||
import { LoreService } from '../../../services/lore.service';
|
import { LoreService } from '../../../services/lore.service';
|
||||||
import { Lore } from '../../../services/lore.model';
|
import { Lore } from '../../../services/lore.model';
|
||||||
import { GameSystemService } from '../../../services/game-system.service';
|
import { GameSystemService } from '../../../services/game-system.service';
|
||||||
@@ -22,7 +23,7 @@ export interface CampaignCreatePayload {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-campaign-create',
|
selector: 'app-campaign-create',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule],
|
imports: [CommonModule, ReactiveFormsModule, FormsModule, LucideAngularModule],
|
||||||
templateUrl: './campaign-create.component.html',
|
templateUrl: './campaign-create.component.html',
|
||||||
styleUrls: ['./campaign-create.component.scss']
|
styleUrls: ['./campaign-create.component.scss']
|
||||||
})
|
})
|
||||||
@@ -32,6 +33,11 @@ export class CampaignCreateComponent implements OnInit {
|
|||||||
|
|
||||||
readonly BookCopy = BookCopy;
|
readonly BookCopy = BookCopy;
|
||||||
readonly X = X;
|
readonly X = X;
|
||||||
|
readonly Plus = Plus;
|
||||||
|
readonly Check = Check;
|
||||||
|
|
||||||
|
/** Valeur sentinelle de l'option "Creer un systeme" dans le <select>. */
|
||||||
|
readonly CREATE_GAMESYSTEM_SENTINEL = '__create__';
|
||||||
|
|
||||||
form: FormGroup;
|
form: FormGroup;
|
||||||
/** Lores disponibles pour association. Chargés à l'ouverture de la modal. */
|
/** Lores disponibles pour association. Chargés à l'ouverture de la modal. */
|
||||||
@@ -39,6 +45,11 @@ export class CampaignCreateComponent implements OnInit {
|
|||||||
/** GameSystems disponibles pour association. */
|
/** GameSystems disponibles pour association. */
|
||||||
availableGameSystems: GameSystem[] = [];
|
availableGameSystems: GameSystem[] = [];
|
||||||
|
|
||||||
|
/** Mode creation inline d'un GameSystem depuis le dropdown. */
|
||||||
|
creatingGameSystem = false;
|
||||||
|
newGameSystemName = '';
|
||||||
|
creatingGameSystemInFlight = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private fb: FormBuilder,
|
private fb: FormBuilder,
|
||||||
private loreService: LoreService,
|
private loreService: LoreService,
|
||||||
@@ -62,6 +73,47 @@ export class CampaignCreateComponent implements OnInit {
|
|||||||
next: (gs) => this.availableGameSystems = gs,
|
next: (gs) => this.availableGameSystems = gs,
|
||||||
error: () => this.availableGameSystems = []
|
error: () => this.availableGameSystems = []
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Detecte la selection de l'option sentinelle "Creer un systeme" et bascule
|
||||||
|
// en mode creation inline. On reinitialise immediatement le control a ''
|
||||||
|
// pour que la sentinelle ne reste pas en valeur reelle du form.
|
||||||
|
this.form.get('gameSystemId')?.valueChanges.subscribe(value => {
|
||||||
|
if (value === this.CREATE_GAMESYSTEM_SENTINEL) {
|
||||||
|
this.form.get('gameSystemId')?.setValue('', { emitEvent: false });
|
||||||
|
this.startCreateGameSystem();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
startCreateGameSystem(): void {
|
||||||
|
this.creatingGameSystem = true;
|
||||||
|
this.newGameSystemName = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelCreateGameSystem(): void {
|
||||||
|
this.creatingGameSystem = false;
|
||||||
|
this.newGameSystemName = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
submitCreateGameSystem(): void {
|
||||||
|
const name = this.newGameSystemName.trim();
|
||||||
|
if (!name || this.creatingGameSystemInFlight) return;
|
||||||
|
this.creatingGameSystemInFlight = true;
|
||||||
|
this.gameSystemService.create({ name, isPublic: false }).subscribe({
|
||||||
|
next: (created) => {
|
||||||
|
this.creatingGameSystemInFlight = false;
|
||||||
|
this.availableGameSystems = [...this.availableGameSystems, created];
|
||||||
|
if (created.id) {
|
||||||
|
this.form.get('gameSystemId')?.setValue(created.id);
|
||||||
|
}
|
||||||
|
this.creatingGameSystem = false;
|
||||||
|
this.newGameSystemName = '';
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.creatingGameSystemInFlight = false;
|
||||||
|
console.error('Erreur lors de la creation du systeme de jeu');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
|
|||||||
@@ -55,10 +55,37 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Système de JDR</label>
|
<label>Système de JDR</label>
|
||||||
<select [(ngModel)]="editGameSystemId" name="editGameSystemId">
|
<select *ngIf="!creatingGameSystem"
|
||||||
|
[(ngModel)]="editGameSystemId"
|
||||||
|
name="editGameSystemId"
|
||||||
|
(ngModelChange)="onEditGameSystemChange($event)">
|
||||||
<option value="">— Aucun (générique) —</option>
|
<option value="">— Aucun (générique) —</option>
|
||||||
<option *ngFor="let gs of availableGameSystems" [value]="gs.id">{{ gs.name }}</option>
|
<option *ngFor="let gs of availableGameSystems" [value]="gs.id">{{ gs.name }}</option>
|
||||||
|
<option [value]="CREATE_GAMESYSTEM_SENTINEL">+ Créer un nouveau système…</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<div *ngIf="creatingGameSystem" class="inline-create">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="newGameSystemName"
|
||||||
|
name="newGameSystemName"
|
||||||
|
placeholder="Nom du nouveau système (ex: D&D 5e, Nimble, Maison)"
|
||||||
|
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
|
||||||
|
(keydown.escape)="cancelCreateGameSystem()"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<div class="inline-create-actions">
|
||||||
|
<button type="button" class="btn-inline-primary"
|
||||||
|
[disabled]="!newGameSystemName.trim() || creatingGameSystemInFlight"
|
||||||
|
(click)="submitCreateGameSystem()">
|
||||||
|
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
|
||||||
|
Créer
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-inline-secondary" (click)="cancelCreateGameSystem()">
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button type="button" class="btn-primary" (click)="saveEdit()" [disabled]="!editName.trim()">
|
<button type="button" class="btn-primary" (click)="saveEdit()" [disabled]="!editName.trim()">
|
||||||
@@ -169,4 +196,60 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ Sessions de jeu ============ -->
|
||||||
|
<section class="detail-section sessions-section" *ngIf="!editing">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>
|
||||||
|
<lucide-icon [img]="Dices" [size]="18"></lucide-icon>
|
||||||
|
Sessions de jeu
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- Cas 1 : aucune session active dans l'app → on peut lancer -->
|
||||||
|
<button *ngIf="!activeSessionGlobal"
|
||||||
|
class="btn-add"
|
||||||
|
[disabled]="startingSession"
|
||||||
|
(click)="startSession()">
|
||||||
|
<lucide-icon [img]="Play" [size]="14"></lucide-icon>
|
||||||
|
Lancer une nouvelle session
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Cas 2 : session active sur cette campagne → reprendre -->
|
||||||
|
<button *ngIf="activeSessionOnCurrentCampaign"
|
||||||
|
class="btn-add"
|
||||||
|
(click)="openSession(activeSessionOnCurrentCampaign)">
|
||||||
|
<lucide-icon [img]="Play" [size]="14"></lucide-icon>
|
||||||
|
Reprendre la session en cours
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Cas 3 : session active sur une autre campagne → bloqué -->
|
||||||
|
<button *ngIf="isLaunchBlockedByOtherCampaign"
|
||||||
|
class="btn-add"
|
||||||
|
disabled
|
||||||
|
title="Une session est déjà en cours sur une autre campagne. Termine-la d'abord.">
|
||||||
|
<lucide-icon [img]="Play" [size]="14"></lucide-icon>
|
||||||
|
Session en cours ailleurs
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sessions-grid" *ngIf="sessions.length > 0">
|
||||||
|
<div class="session-card"
|
||||||
|
*ngFor="let session of sessions"
|
||||||
|
[class.session-card--active]="session.active"
|
||||||
|
(click)="openSession(session)">
|
||||||
|
<lucide-icon [img]="Dices" [size]="20" class="session-icon"></lucide-icon>
|
||||||
|
<div class="session-info">
|
||||||
|
<span class="session-name">{{ session.name }}</span>
|
||||||
|
<span class="session-meta">
|
||||||
|
<span class="session-status" *ngIf="session.active">● En cours</span>
|
||||||
|
<span *ngIf="!session.active">Terminée le {{ session.endedAt | date:'dd/MM/yyyy' }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="empty-state empty-state--compact" *ngIf="sessions.length === 0">
|
||||||
|
<p>Aucune session de jeu pour le moment.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -122,6 +122,64 @@
|
|||||||
textarea { resize: vertical; }
|
textarea { resize: vertical; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-create {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.875rem;
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid #1f2937;
|
||||||
|
border-left: 3px solid #6c63ff;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
background: #0a1320;
|
||||||
|
border: 1px solid #1f2937;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&::placeholder { color: #4b5563; }
|
||||||
|
&:focus { border-color: #6c63ff; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-create-actions { display: flex; gap: 0.5rem; }
|
||||||
|
|
||||||
|
.btn-inline-primary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.45rem 0.875rem;
|
||||||
|
background: #6c63ff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) { background: #5b52e0; }
|
||||||
|
&:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-inline-secondary {
|
||||||
|
padding: 0.45rem 0.875rem;
|
||||||
|
background: transparent;
|
||||||
|
color: #9ca3af;
|
||||||
|
border: 1px solid #374151;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
|
||||||
|
&:hover { background: #1f2937; color: white; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.header-actions { justify-content: flex-end; }
|
.header-actions { justify-content: flex-end; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,3 +382,57 @@
|
|||||||
|
|
||||||
&:hover { background: #5b52e0; }
|
&:hover { background: #5b52e0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────── Sessions de jeu (Play Context) ───────────────
|
||||||
|
.sessions-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
background: #111827;
|
||||||
|
border: 1px solid #1f2937;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, transform 0.2s;
|
||||||
|
|
||||||
|
&:hover { border-color: #6c63ff; transform: translateY(-1px); }
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
border-color: #10b981;
|
||||||
|
background: linear-gradient(180deg, #0d1f1a 0%, #111827 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-icon { color: #6c63ff; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.session-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-name {
|
||||||
|
color: white;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-meta {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-status {
|
||||||
|
color: #10b981;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, User, Dices, Drama } from 'lucide-angular';
|
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, User, Dices, Drama, Check, Play } from 'lucide-angular';
|
||||||
import { Router, RouterLink } from '@angular/router';
|
import { Router, RouterLink } from '@angular/router';
|
||||||
import { forkJoin, of } from 'rxjs';
|
import { forkJoin, of } from 'rxjs';
|
||||||
import { catchError, switchMap, filter, map } from 'rxjs/operators';
|
import { catchError, switchMap, filter, map } from 'rxjs/operators';
|
||||||
@@ -12,13 +12,16 @@ import { GameSystemService } from '../../../services/game-system.service';
|
|||||||
import { GameSystem } from '../../../services/game-system.model';
|
import { GameSystem } from '../../../services/game-system.model';
|
||||||
import { CharacterService } from '../../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { NpcService } from '../../../services/npc.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
|
import { SessionService } from '../../../services/session.service';
|
||||||
|
import { Session } from '../../../services/session.model';
|
||||||
import { Character } from '../../../services/character.model';
|
import { Character } from '../../../services/character.model';
|
||||||
import { Npc } from '../../../services/npc.model';
|
import { Npc } from '../../../services/npc.model';
|
||||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
import { LayoutService } from '../../../services/layout.service';
|
||||||
import { PageTitleService } from '../../../services/page-title.service';
|
import { PageTitleService } from '../../../services/page-title.service';
|
||||||
import { Campaign, Arc } from '../../../services/campaign.model';
|
import { Campaign, Arc } from '../../../services/campaign.model';
|
||||||
import { Lore } from '../../../services/lore.model';
|
import { Lore } from '../../../services/lore.model';
|
||||||
import { loadCampaignTreeData, buildCampaignTree, CampaignTreeData } from '../../campaign-tree.helper';
|
import { loadCampaignTreeData, buildCampaignSidebarConfig, CampaignTreeData } from '../../campaign-tree.helper';
|
||||||
|
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-campaign-detail',
|
selector: 'app-campaign-detail',
|
||||||
@@ -36,6 +39,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
readonly User = User;
|
readonly User = User;
|
||||||
readonly Dices = Dices;
|
readonly Dices = Dices;
|
||||||
readonly Drama = Drama;
|
readonly Drama = Drama;
|
||||||
|
readonly Check = Check;
|
||||||
|
readonly Play = Play;
|
||||||
|
|
||||||
campaign: Campaign | null = null;
|
campaign: Campaign | null = null;
|
||||||
arcs: Arc[] = [];
|
arcs: Arc[] = [];
|
||||||
@@ -53,6 +58,16 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
characters: Character[] = [];
|
characters: Character[] = [];
|
||||||
/** Fiches de personnages non-joueurs (PNJ) de la campagne. */
|
/** Fiches de personnages non-joueurs (PNJ) de la campagne. */
|
||||||
npcs: Npc[] = [];
|
npcs: Npc[] = [];
|
||||||
|
/** Sessions de jeu (passées et en cours) liées à cette campagne. */
|
||||||
|
sessions: Session[] = [];
|
||||||
|
/**
|
||||||
|
* Session active globale (toutes campagnes confondues).
|
||||||
|
* Sert à désactiver le bouton "Lancer" si une session tourne déjà ailleurs.
|
||||||
|
* Null si aucune session active dans l'app.
|
||||||
|
*/
|
||||||
|
activeSessionGlobal: Session | null = null;
|
||||||
|
/** Indicateur de lancement en cours pour éviter les double-clics. */
|
||||||
|
startingSession = false;
|
||||||
|
|
||||||
/** Mode édition inline. */
|
/** Mode édition inline. */
|
||||||
editing = false;
|
editing = false;
|
||||||
@@ -61,6 +76,13 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
editLoreId = '';
|
editLoreId = '';
|
||||||
editGameSystemId = '';
|
editGameSystemId = '';
|
||||||
|
|
||||||
|
/** Valeur sentinelle de l'option "Creer un systeme" dans le <select>. */
|
||||||
|
readonly CREATE_GAMESYSTEM_SENTINEL = '__create__';
|
||||||
|
/** Mode creation inline d'un GameSystem depuis le dropdown d'edition. */
|
||||||
|
creatingGameSystem = false;
|
||||||
|
newGameSystemName = '';
|
||||||
|
creatingGameSystemInFlight = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
@@ -69,8 +91,10 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
private gameSystemService: GameSystemService,
|
private gameSystemService: GameSystemService,
|
||||||
private characterService: CharacterService,
|
private characterService: CharacterService,
|
||||||
private npcService: NpcService,
|
private npcService: NpcService,
|
||||||
|
private sessionService: SessionService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService,
|
||||||
|
private confirmDialog: ConfirmDialogService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -94,6 +118,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
this.loadLinkedGameSystem(campaign);
|
this.loadLinkedGameSystem(campaign);
|
||||||
this.loadCharacters(campaign.id!);
|
this.loadCharacters(campaign.id!);
|
||||||
this.loadNpcs(campaign.id!);
|
this.loadNpcs(campaign.id!);
|
||||||
|
this.loadSessions(campaign.id!);
|
||||||
this.arcs = treeData.arcs;
|
this.arcs = treeData.arcs;
|
||||||
this.chapterCountByArc = this.computeChapterCounts(treeData);
|
this.chapterCountByArc = this.computeChapterCounts(treeData);
|
||||||
this.showLayout(allCampaigns, treeData);
|
this.showLayout(allCampaigns, treeData);
|
||||||
@@ -128,6 +153,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
this.loadLinkedGameSystem(campaign);
|
this.loadLinkedGameSystem(campaign);
|
||||||
this.loadCharacters(campaign.id!);
|
this.loadCharacters(campaign.id!);
|
||||||
this.loadNpcs(campaign.id!);
|
this.loadNpcs(campaign.id!);
|
||||||
|
this.loadSessions(campaign.id!);
|
||||||
this.arcs = treeData.arcs;
|
this.arcs = treeData.arcs;
|
||||||
this.chapterCountByArc = this.computeChapterCounts(treeData);
|
this.chapterCountByArc = this.computeChapterCounts(treeData);
|
||||||
this.showLayout(allCampaigns, treeData);
|
this.showLayout(allCampaigns, treeData);
|
||||||
@@ -174,6 +200,55 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
).subscribe(list => this.npcs = list);
|
).subscribe(list => this.npcs = list);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge les sessions de cette campagne ET la session active globale.
|
||||||
|
* La session globale conditionne si le bouton "Lancer" est activable
|
||||||
|
* (règle métier : une seule session active simultanément dans l'app).
|
||||||
|
*/
|
||||||
|
private loadSessions(campaignId: string): void {
|
||||||
|
this.sessionService.getSessions(campaignId).pipe(
|
||||||
|
catchError(() => of([] as Session[]))
|
||||||
|
).subscribe(list => this.sessions = list);
|
||||||
|
|
||||||
|
this.sessionService.getActiveSession().pipe(
|
||||||
|
catchError(() => of(null))
|
||||||
|
).subscribe(active => this.activeSessionGlobal = active);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True si une session est active mais sur une AUTRE campagne (lancement bloqué). */
|
||||||
|
get isLaunchBlockedByOtherCampaign(): boolean {
|
||||||
|
return !!this.activeSessionGlobal
|
||||||
|
&& !!this.campaign
|
||||||
|
&& this.activeSessionGlobal.campaignId !== this.campaign.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Session active sur la campagne courante (le MJ joue déjà ici). */
|
||||||
|
get activeSessionOnCurrentCampaign(): Session | null {
|
||||||
|
if (!this.activeSessionGlobal || !this.campaign) return null;
|
||||||
|
return this.activeSessionGlobal.campaignId === this.campaign.id
|
||||||
|
? this.activeSessionGlobal
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
startSession(): void {
|
||||||
|
if (!this.campaign || this.startingSession || this.isLaunchBlockedByOtherCampaign) return;
|
||||||
|
this.startingSession = true;
|
||||||
|
this.sessionService.startSession(this.campaign.id!).subscribe({
|
||||||
|
next: session => {
|
||||||
|
this.startingSession = false;
|
||||||
|
this.router.navigate(['/sessions', session.id]);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.startingSession = false;
|
||||||
|
console.error('Erreur lors du lancement de la session');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openSession(session: Session): void {
|
||||||
|
this.router.navigate(['/sessions', session.id]);
|
||||||
|
}
|
||||||
|
|
||||||
createCharacter(): void {
|
createCharacter(): void {
|
||||||
if (!this.campaign) return;
|
if (!this.campaign) return;
|
||||||
this.router.navigate(['/campaigns', this.campaign.id, 'characters', 'create']);
|
this.router.navigate(['/campaigns', this.campaign.id, 'characters', 'create']);
|
||||||
@@ -241,24 +316,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private showLayout(allCampaigns: Campaign[], data: CampaignTreeData): void {
|
private showLayout(allCampaigns: Campaign[], data: CampaignTreeData): void {
|
||||||
const campaignId = this.campaign!.id!;
|
this.layoutService.show(buildCampaignSidebarConfig(this.campaign!, allCampaigns, data, this.campaign!.id!));
|
||||||
const globalItems: GlobalItem[] = allCampaigns.map(c => ({
|
|
||||||
id: c.id!,
|
|
||||||
name: c.name,
|
|
||||||
route: `/campaigns/${c.id}`
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.layoutService.show({
|
|
||||||
title: this.campaign!.name,
|
|
||||||
items: buildCampaignTree(campaignId, data),
|
|
||||||
footerLabel: 'Toutes les campagnes',
|
|
||||||
createActions: [
|
|
||||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${campaignId}/arcs/create` }
|
|
||||||
],
|
|
||||||
globalItems,
|
|
||||||
globalBackLabel: 'Toutes les campagnes',
|
|
||||||
globalBackRoute: '/campaigns'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────── Édition / suppression de la Campagne ───────────────
|
// ─────────────── Édition / suppression de la Campagne ───────────────
|
||||||
@@ -283,16 +341,83 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
cancelEdit(): void {
|
cancelEdit(): void {
|
||||||
this.editing = false;
|
this.editing = false;
|
||||||
|
this.creatingGameSystem = false;
|
||||||
|
this.newGameSystemName = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detecte la selection de l'option sentinelle dans le <select> GameSystem. */
|
||||||
|
onEditGameSystemChange(value: string): void {
|
||||||
|
if (value === this.CREATE_GAMESYSTEM_SENTINEL) {
|
||||||
|
this.editGameSystemId = '';
|
||||||
|
this.startCreateGameSystem();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startCreateGameSystem(): void {
|
||||||
|
this.creatingGameSystem = true;
|
||||||
|
this.newGameSystemName = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelCreateGameSystem(): void {
|
||||||
|
this.creatingGameSystem = false;
|
||||||
|
this.newGameSystemName = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
submitCreateGameSystem(): void {
|
||||||
|
const name = this.newGameSystemName.trim();
|
||||||
|
if (!name || this.creatingGameSystemInFlight) return;
|
||||||
|
this.creatingGameSystemInFlight = true;
|
||||||
|
this.gameSystemService.create({ name, isPublic: false }).subscribe({
|
||||||
|
next: (created) => {
|
||||||
|
this.creatingGameSystemInFlight = false;
|
||||||
|
this.availableGameSystems = [...this.availableGameSystems, created];
|
||||||
|
if (created.id) {
|
||||||
|
this.editGameSystemId = created.id;
|
||||||
|
}
|
||||||
|
this.creatingGameSystem = false;
|
||||||
|
this.newGameSystemName = '';
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.creatingGameSystemInFlight = false;
|
||||||
|
console.error('Erreur lors de la creation du systeme de jeu');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
saveEdit(): void {
|
saveEdit(): void {
|
||||||
if (!this.campaign || !this.editName.trim()) return;
|
if (!this.campaign || !this.editName.trim()) return;
|
||||||
|
const newGameSystemId = this.editGameSystemId ? this.editGameSystemId : null;
|
||||||
|
const currentGameSystemId = this.campaign.gameSystemId ?? null;
|
||||||
|
const gameSystemChanged = newGameSystemId !== currentGameSystemId;
|
||||||
|
const hasSheets = this.characters.length > 0 || this.npcs.length > 0;
|
||||||
|
if (gameSystemChanged && hasSheets) {
|
||||||
|
const count = this.characters.length + this.npcs.length;
|
||||||
|
this.confirmDialog.confirm({
|
||||||
|
title: 'Changer le systeme de jeu ?',
|
||||||
|
message:
|
||||||
|
`Vous etes sur le point de changer le systeme de jeu de cette campagne. ` +
|
||||||
|
`Cela change egalement le template des fiches de PJ et PNJ.`,
|
||||||
|
details: [
|
||||||
|
`${count} fiche(s) existante(s) sont liees au template du systeme actuel.`,
|
||||||
|
`Leurs champs ne s'afficheront plus avec le nouveau systeme.`,
|
||||||
|
`Les donnees restent stockees : revenir a l'ancien systeme les rendra a nouveau visibles.`
|
||||||
|
],
|
||||||
|
confirmLabel: 'Changer quand meme',
|
||||||
|
variant: 'warning'
|
||||||
|
}).then(ok => { if (ok) this.persistEdit(newGameSystemId); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.persistEdit(newGameSystemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private persistEdit(newGameSystemId: string | null): void {
|
||||||
|
if (!this.campaign) return;
|
||||||
this.campaignService.updateCampaign(this.campaign.id!, {
|
this.campaignService.updateCampaign(this.campaign.id!, {
|
||||||
name: this.editName.trim(),
|
name: this.editName.trim(),
|
||||||
description: this.editDescription,
|
description: this.editDescription,
|
||||||
playerCount: this.campaign.playerCount ?? 0,
|
playerCount: this.campaign.playerCount ?? 0,
|
||||||
loreId: this.editLoreId ? this.editLoreId : null,
|
loreId: this.editLoreId ? this.editLoreId : null,
|
||||||
gameSystemId: this.editGameSystemId ? this.editGameSystemId : null
|
gameSystemId: newGameSystemId
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: (updated) => {
|
next: (updated) => {
|
||||||
this.campaign = updated;
|
this.campaign = updated;
|
||||||
@@ -321,19 +446,23 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
if (impact.scenes > 0) parts.push(`${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}`);
|
if (impact.scenes > 0) parts.push(`${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}`);
|
||||||
if (impact.characters > 0) parts.push(`${impact.characters} personnage${impact.characters > 1 ? 's' : ''}`);
|
if (impact.characters > 0) parts.push(`${impact.characters} personnage${impact.characters > 1 ? 's' : ''}`);
|
||||||
|
|
||||||
const lines = [`Supprimer définitivement la campagne "${campaign.name}" ?`];
|
const details: string[] = [];
|
||||||
if (parts.length) {
|
if (parts.length) details.push(`Sera aussi supprime : ${parts.join(', ')}.`);
|
||||||
lines.push('');
|
details.push('Cette action est irreversible.');
|
||||||
lines.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
|
|
||||||
}
|
|
||||||
lines.push('');
|
|
||||||
lines.push('Cette action est irréversible.');
|
|
||||||
|
|
||||||
if (!confirm(lines.join('\n'))) return;
|
this.confirmDialog.confirm({
|
||||||
|
title: 'Supprimer la campagne ?',
|
||||||
|
message: `Supprimer definitivement la campagne "${campaign.name}" ?`,
|
||||||
|
details,
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
this.campaignService.deleteCampaign(campaign.id!).subscribe({
|
this.campaignService.deleteCampaign(campaign.id!).subscribe({
|
||||||
next: () => this.router.navigate(['/campaigns']),
|
next: () => this.router.navigate(['/campaigns']),
|
||||||
error: () => console.error('Erreur lors de la suppression de la campagne')
|
error: () => console.error('Erreur lors de la suppression de la campagne')
|
||||||
});
|
});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
error: () => console.error('Impossible de récupérer les dépendances de la campagne')
|
error: () => console.error('Impossible de récupérer les dépendances de la campagne')
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { LucideAngularModule, Map, Plus } from 'lucide-angular';
|
import { LucideAngularModule, Map, Plus } from 'lucide-angular';
|
||||||
import { CampaignService } from '../services/campaign.service';
|
import { CampaignService } from '../services/campaign.service';
|
||||||
|
import { LayoutService } from '../services/layout.service';
|
||||||
import { Campaign } from '../services/campaign.model';
|
import { Campaign } from '../services/campaign.model';
|
||||||
import { CampaignCreateComponent, CampaignCreatePayload } from './campaign/campaign-create/campaign-create.component';
|
import { CampaignCreateComponent, CampaignCreatePayload } from './campaign/campaign-create/campaign-create.component';
|
||||||
|
|
||||||
@@ -22,10 +23,15 @@ export class CampaignsComponent implements OnInit {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private campaignService: CampaignService
|
private campaignService: CampaignService,
|
||||||
|
private layoutService: LayoutService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
// Liste racine de la section Campagnes : aucune sidebar secondaire ne
|
||||||
|
// doit subsister (ex: si on arrive depuis une page Lore qui en affichait
|
||||||
|
// une, elle persisterait sans ce hide() — cf. bug rapporte 2026-05-19).
|
||||||
|
this.layoutService.hide();
|
||||||
this.loadCampaigns();
|
this.loadCampaigns();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ import { LucideAngularModule } from 'lucide-angular';
|
|||||||
import { CampaignService } from '../../../services/campaign.service';
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
import { CharacterService } from '../../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { NpcService } from '../../../services/npc.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
import { LayoutService } from '../../../services/layout.service';
|
||||||
import { Campaign } from '../../../services/campaign.model';
|
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
|
||||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||||
|
|
||||||
@@ -65,21 +64,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
|
|||||||
this.arcName = currentArc?.name ?? '';
|
this.arcName = currentArc?.name ?? '';
|
||||||
this.existingChapterCount = treeData.chaptersByArc[this.arcId]?.length ?? 0;
|
this.existingChapterCount = treeData.chaptersByArc[this.arcId]?.length ?? 0;
|
||||||
|
|
||||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
|
||||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.layoutService.show({
|
|
||||||
title: campaign.name,
|
|
||||||
items: buildCampaignTree(this.campaignId, treeData),
|
|
||||||
footerLabel: 'Toutes les campagnes',
|
|
||||||
createActions: [
|
|
||||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
|
||||||
],
|
|
||||||
globalItems,
|
|
||||||
globalBackLabel: 'Toutes les campagnes',
|
|
||||||
globalBackRoute: '/campaigns'
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +87,9 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,16 +9,17 @@ import { CampaignService } from '../../../services/campaign.service';
|
|||||||
import { CharacterService } from '../../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { NpcService } from '../../../services/npc.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { PageService } from '../../../services/page.service';
|
import { PageService } from '../../../services/page.service';
|
||||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
import { LayoutService } from '../../../services/layout.service';
|
||||||
import { PageTitleService } from '../../../services/page-title.service';
|
import { PageTitleService } from '../../../services/page-title.service';
|
||||||
import { Campaign, Chapter } from '../../../services/campaign.model';
|
import { Chapter } from '../../../services/campaign.model';
|
||||||
import { Page } from '../../../services/page.model';
|
import { Page } from '../../../services/page.model';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||||
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
|
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
|
||||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||||
|
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran de détail/modification d'un Chapitre.
|
* Écran de détail/modification d'un Chapitre.
|
||||||
@@ -71,7 +72,8 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
|||||||
private npcService: NpcService,
|
private npcService: NpcService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService,
|
||||||
|
private confirmDialog: ConfirmDialogService
|
||||||
) {
|
) {
|
||||||
this.form = this.fb.group({
|
this.form = this.fb.group({
|
||||||
name: ['', Validators.required],
|
name: ['', Validators.required],
|
||||||
@@ -130,21 +132,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
|||||||
narrativeStakes: chapter.narrativeStakes ?? ''
|
narrativeStakes: chapter.narrativeStakes ?? ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
|
||||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.layoutService.show({
|
|
||||||
title: campaign.name,
|
|
||||||
items: buildCampaignTree(this.campaignId, treeData),
|
|
||||||
footerLabel: 'Toutes les campagnes',
|
|
||||||
createActions: [
|
|
||||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
|
||||||
],
|
|
||||||
globalItems,
|
|
||||||
globalBackLabel: 'Toutes les campagnes',
|
|
||||||
globalBackRoute: '/campaigns'
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,11 +157,19 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
delete(): void {
|
delete(): void {
|
||||||
if (!confirm(`Supprimer le chapitre "${this.chapter?.name}" ? Cette action est irréversible.`)) return;
|
this.confirmDialog.confirm({
|
||||||
|
title: 'Supprimer le chapitre',
|
||||||
|
message: `Supprimer le chapitre "${this.chapter?.name}" ?`,
|
||||||
|
details: ['Cette action est irréversible.'],
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
this.campaignService.deleteChapter(this.chapterId).subscribe({
|
this.campaignService.deleteChapter(this.chapterId).subscribe({
|
||||||
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
||||||
error: () => console.error('Erreur lors de la suppression')
|
error: () => console.error('Erreur lors de la suppression')
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel(): void {
|
cancel(): void {
|
||||||
@@ -181,6 +177,9 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -371,6 +371,9 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import { CampaignService } from '../../../services/campaign.service';
|
|||||||
import { CharacterService } from '../../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { NpcService } from '../../../services/npc.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { PageService } from '../../../services/page.service';
|
import { PageService } from '../../../services/page.service';
|
||||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
import { LayoutService } from '../../../services/layout.service';
|
||||||
import { PageTitleService } from '../../../services/page-title.service';
|
import { PageTitleService } from '../../../services/page-title.service';
|
||||||
import { Campaign, Chapter } from '../../../services/campaign.model';
|
import { Chapter } from '../../../services/campaign.model';
|
||||||
import { Page } from '../../../services/page.model';
|
import { Page } from '../../../services/page.model';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||||
|
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran de consultation d'un Chapitre (lecture seule).
|
* Écran de consultation d'un Chapitre (lecture seule).
|
||||||
@@ -49,7 +50,8 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
|||||||
private npcService: NpcService,
|
private npcService: NpcService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService,
|
||||||
|
private confirmDialog: ConfirmDialogService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -86,20 +88,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
|||||||
this.availablePages = pages;
|
this.availablePages = pages;
|
||||||
this.pageTitleService.set(chapter.name);
|
this.pageTitleService.set(chapter.name);
|
||||||
|
|
||||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
|
||||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
|
||||||
}));
|
|
||||||
this.layoutService.show({
|
|
||||||
title: campaign.name,
|
|
||||||
items: buildCampaignTree(this.campaignId, treeData),
|
|
||||||
footerLabel: 'Toutes les campagnes',
|
|
||||||
createActions: [
|
|
||||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
|
||||||
],
|
|
||||||
globalItems,
|
|
||||||
globalBackLabel: 'Toutes les campagnes',
|
|
||||||
globalBackRoute: '/campaigns'
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,25 +117,34 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
|||||||
const chapter = this.chapter;
|
const chapter = this.chapter;
|
||||||
this.campaignService.getChapterDeletionImpact(chapter.id!).subscribe({
|
this.campaignService.getChapterDeletionImpact(chapter.id!).subscribe({
|
||||||
next: impact => {
|
next: impact => {
|
||||||
const lines = [`Supprimer le chapitre "${chapter.name}" ?`];
|
const details: string[] = [];
|
||||||
if (impact.scenes > 0) {
|
if (impact.scenes > 0) {
|
||||||
lines.push('');
|
details.push(`Cette action supprimera aussi : ${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}.`);
|
||||||
lines.push(`Cette action supprimera aussi : ${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}.`);
|
|
||||||
}
|
}
|
||||||
lines.push('');
|
details.push('Cette action est irréversible.');
|
||||||
lines.push('Cette action est irréversible.');
|
|
||||||
|
|
||||||
if (!confirm(lines.join('\n'))) return;
|
this.confirmDialog.confirm({
|
||||||
|
title: 'Supprimer le chapitre',
|
||||||
|
message: `Supprimer le chapitre "${chapter.name}" ?`,
|
||||||
|
details,
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
this.campaignService.deleteChapter(chapter.id!).subscribe({
|
this.campaignService.deleteChapter(chapter.id!).subscribe({
|
||||||
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]),
|
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]),
|
||||||
error: () => console.error('Erreur lors de la suppression du chapitre')
|
error: () => console.error('Erreur lors de la suppression du chapitre')
|
||||||
});
|
});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
error: () => console.error('Impossible de récupérer les dépendances du chapitre')
|
error: () => console.error('Impossible de récupérer les dépendances du chapitre')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,9 @@
|
|||||||
<div class="ce-form">
|
<div class="ce-form">
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Nom du personnage *</label>
|
<label for="character-name">Nom du personnage *</label>
|
||||||
<input
|
<input
|
||||||
|
id="character-name"
|
||||||
type="text"
|
type="text"
|
||||||
[(ngModel)]="name"
|
[(ngModel)]="name"
|
||||||
name="name"
|
name="name"
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lu
|
|||||||
import { CharacterService } from '../../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { CampaignService } from '../../../services/campaign.service';
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
import { GameSystemService } from '../../../services/game-system.service';
|
import { GameSystemService } from '../../../services/game-system.service';
|
||||||
|
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||||
import { TemplateField } from '../../../services/template.model';
|
import { TemplateField } from '../../../services/template.model';
|
||||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
|
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
|
||||||
import { SingleImagePickerComponent } from '../../../shared/single-image-picker/single-image-picker.component';
|
import { SingleImagePickerComponent } from '../../../shared/single-image-picker/single-image-picker.component';
|
||||||
|
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Editeur plein ecran d'une fiche de personnage (PJ).
|
* Editeur plein ecran d'une fiche de personnage (PJ).
|
||||||
@@ -62,7 +64,9 @@ export class CharacterEditComponent implements OnInit {
|
|||||||
private router: Router,
|
private router: Router,
|
||||||
private service: CharacterService,
|
private service: CharacterService,
|
||||||
private campaignService: CampaignService,
|
private campaignService: CampaignService,
|
||||||
private gameSystemService: GameSystemService
|
private gameSystemService: GameSystemService,
|
||||||
|
private campaignSidebar: CampaignSidebarService,
|
||||||
|
private confirmDialog: ConfirmDialogService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -72,6 +76,7 @@ export class CharacterEditComponent implements OnInit {
|
|||||||
|
|
||||||
if (this.campaignId) {
|
if (this.campaignId) {
|
||||||
this.loadTemplateForCampaign(this.campaignId);
|
this.loadTemplateForCampaign(this.campaignId);
|
||||||
|
this.campaignSidebar.show(this.campaignId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.characterId) {
|
if (this.characterId) {
|
||||||
@@ -106,6 +111,7 @@ export class CharacterEditComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
if (!this.name.trim() || !this.campaignId) return;
|
if (!this.name.trim() || !this.campaignId) return;
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -117,22 +123,37 @@ export class CharacterEditComponent implements OnInit {
|
|||||||
keyValueValues: this.keyValueValues,
|
keyValueValues: this.keyValueValues,
|
||||||
campaignId: this.campaignId
|
campaignId: this.campaignId
|
||||||
};
|
};
|
||||||
|
const isCreation = !this.characterId;
|
||||||
const req = this.characterId
|
const req = this.characterId
|
||||||
? this.service.update(this.characterId, { ...payload, id: this.characterId, order: this.order })
|
? this.service.update(this.characterId, { ...payload, id: this.characterId, order: this.order })
|
||||||
: this.service.create(payload);
|
: this.service.create(payload);
|
||||||
req.subscribe({
|
req.subscribe({
|
||||||
next: () => this.back(),
|
next: (saved) => {
|
||||||
|
if (isCreation && saved.id) {
|
||||||
|
this.router.navigate(['/campaigns', this.campaignId, 'characters', saved.id]);
|
||||||
|
} else {
|
||||||
|
this.back();
|
||||||
|
}
|
||||||
|
},
|
||||||
error: () => console.error('Erreur sauvegarde Character')
|
error: () => console.error('Erreur sauvegarde Character')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteCharacter(): void {
|
deleteCharacter(): void {
|
||||||
if (!this.characterId) return;
|
if (!this.characterId) return;
|
||||||
if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irreversible.`)) return;
|
this.confirmDialog.confirm({
|
||||||
|
title: 'Supprimer la fiche ?',
|
||||||
|
message: `Supprimer la fiche de "${this.name}" ?`,
|
||||||
|
details: ['Cette action est irreversible.'],
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok || !this.characterId) return;
|
||||||
this.service.delete(this.characterId).subscribe({
|
this.service.delete(this.characterId).subscribe({
|
||||||
next: () => this.back(),
|
next: () => this.back(),
|
||||||
error: () => console.error('Erreur suppression Character')
|
error: () => console.error('Erreur suppression Character')
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
back(): void {
|
back(): void {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular'
|
|||||||
import { CharacterService } from '../../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { CampaignService } from '../../../services/campaign.service';
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
import { GameSystemService } from '../../../services/game-system.service';
|
import { GameSystemService } from '../../../services/game-system.service';
|
||||||
|
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||||
import { TemplateField } from '../../../services/template.model';
|
import { TemplateField } from '../../../services/template.model';
|
||||||
import { Character } from '../../../services/character.model';
|
import { Character } from '../../../services/character.model';
|
||||||
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
|
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
|
||||||
@@ -40,7 +41,8 @@ export class CharacterViewComponent implements OnInit {
|
|||||||
private router: Router,
|
private router: Router,
|
||||||
private service: CharacterService,
|
private service: CharacterService,
|
||||||
private campaignService: CampaignService,
|
private campaignService: CampaignService,
|
||||||
private gameSystemService: GameSystemService
|
private gameSystemService: GameSystemService,
|
||||||
|
private campaignSidebar: CampaignSidebarService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -54,6 +56,7 @@ export class CharacterViewComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (this.campaignId) {
|
if (this.campaignId) {
|
||||||
|
this.campaignSidebar.show(this.campaignId);
|
||||||
this.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
|
this.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
|
||||||
if (camp.gameSystemId) {
|
if (camp.gameSystemId) {
|
||||||
this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => {
|
this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => {
|
||||||
|
|||||||
@@ -26,8 +26,9 @@
|
|||||||
<div class="ne-form">
|
<div class="ne-form">
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Nom du PNJ *</label>
|
<label for="npc-name">Nom du PNJ *</label>
|
||||||
<input
|
<input
|
||||||
|
id="npc-name"
|
||||||
type="text"
|
type="text"
|
||||||
[(ngModel)]="name"
|
[(ngModel)]="name"
|
||||||
name="name"
|
name="name"
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import { LucideAngularModule, Save, ArrowLeft, Drama, Trash2, Sparkles } from 'l
|
|||||||
import { NpcService } from '../../../services/npc.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { CampaignService } from '../../../services/campaign.service';
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
import { GameSystemService } from '../../../services/game-system.service';
|
import { GameSystemService } from '../../../services/game-system.service';
|
||||||
|
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||||
import { TemplateField } from '../../../services/template.model';
|
import { TemplateField } from '../../../services/template.model';
|
||||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
|
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
|
||||||
import { SingleImagePickerComponent } from '../../../shared/single-image-picker/single-image-picker.component';
|
import { SingleImagePickerComponent } from '../../../shared/single-image-picker/single-image-picker.component';
|
||||||
|
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Editeur plein ecran d'une fiche de PNJ.
|
* Editeur plein ecran d'une fiche de PNJ.
|
||||||
@@ -57,7 +59,9 @@ export class NpcEditComponent implements OnInit {
|
|||||||
private router: Router,
|
private router: Router,
|
||||||
private service: NpcService,
|
private service: NpcService,
|
||||||
private campaignService: CampaignService,
|
private campaignService: CampaignService,
|
||||||
private gameSystemService: GameSystemService
|
private gameSystemService: GameSystemService,
|
||||||
|
private campaignSidebar: CampaignSidebarService,
|
||||||
|
private confirmDialog: ConfirmDialogService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -67,6 +71,7 @@ export class NpcEditComponent implements OnInit {
|
|||||||
|
|
||||||
if (this.campaignId) {
|
if (this.campaignId) {
|
||||||
this.loadTemplateForCampaign(this.campaignId);
|
this.loadTemplateForCampaign(this.campaignId);
|
||||||
|
this.campaignSidebar.show(this.campaignId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.npcId) {
|
if (this.npcId) {
|
||||||
@@ -101,6 +106,7 @@ export class NpcEditComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
if (!this.name.trim() || !this.campaignId) return;
|
if (!this.name.trim() || !this.campaignId) return;
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -112,22 +118,37 @@ export class NpcEditComponent implements OnInit {
|
|||||||
keyValueValues: this.keyValueValues,
|
keyValueValues: this.keyValueValues,
|
||||||
campaignId: this.campaignId
|
campaignId: this.campaignId
|
||||||
};
|
};
|
||||||
|
const isCreation = !this.npcId;
|
||||||
const req = this.npcId
|
const req = this.npcId
|
||||||
? this.service.update(this.npcId, { ...payload, id: this.npcId, order: this.order })
|
? this.service.update(this.npcId, { ...payload, id: this.npcId, order: this.order })
|
||||||
: this.service.create(payload);
|
: this.service.create(payload);
|
||||||
req.subscribe({
|
req.subscribe({
|
||||||
next: () => this.back(),
|
next: (saved) => {
|
||||||
|
if (isCreation && saved.id) {
|
||||||
|
this.router.navigate(['/campaigns', this.campaignId, 'npcs', saved.id]);
|
||||||
|
} else {
|
||||||
|
this.back();
|
||||||
|
}
|
||||||
|
},
|
||||||
error: () => console.error('Erreur sauvegarde Npc')
|
error: () => console.error('Erreur sauvegarde Npc')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteNpc(): void {
|
deleteNpc(): void {
|
||||||
if (!this.npcId) return;
|
if (!this.npcId) return;
|
||||||
if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irreversible.`)) return;
|
this.confirmDialog.confirm({
|
||||||
|
title: 'Supprimer la fiche ?',
|
||||||
|
message: `Supprimer la fiche de "${this.name}" ?`,
|
||||||
|
details: ['Cette action est irreversible.'],
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok || !this.npcId) return;
|
||||||
this.service.delete(this.npcId).subscribe({
|
this.service.delete(this.npcId).subscribe({
|
||||||
next: () => this.back(),
|
next: () => this.back(),
|
||||||
error: () => console.error('Erreur suppression Npc')
|
error: () => console.error('Erreur suppression Npc')
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
back(): void {
|
back(): void {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular'
|
|||||||
import { NpcService } from '../../../services/npc.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { CampaignService } from '../../../services/campaign.service';
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
import { GameSystemService } from '../../../services/game-system.service';
|
import { GameSystemService } from '../../../services/game-system.service';
|
||||||
|
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||||
import { TemplateField } from '../../../services/template.model';
|
import { TemplateField } from '../../../services/template.model';
|
||||||
import { Npc } from '../../../services/npc.model';
|
import { Npc } from '../../../services/npc.model';
|
||||||
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
|
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
|
||||||
@@ -40,7 +41,8 @@ export class NpcViewComponent implements OnInit {
|
|||||||
private router: Router,
|
private router: Router,
|
||||||
private service: NpcService,
|
private service: NpcService,
|
||||||
private campaignService: CampaignService,
|
private campaignService: CampaignService,
|
||||||
private gameSystemService: GameSystemService
|
private gameSystemService: GameSystemService,
|
||||||
|
private campaignSidebar: CampaignSidebarService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -54,6 +56,7 @@ export class NpcViewComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (this.campaignId) {
|
if (this.campaignId) {
|
||||||
|
this.campaignSidebar.show(this.campaignId);
|
||||||
this.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
|
this.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
|
||||||
if (camp.gameSystemId) {
|
if (camp.gameSystemId) {
|
||||||
this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => {
|
this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => {
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ import { LucideAngularModule } from 'lucide-angular';
|
|||||||
import { CampaignService } from '../../../services/campaign.service';
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
import { CharacterService } from '../../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { NpcService } from '../../../services/npc.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
import { LayoutService } from '../../../services/layout.service';
|
||||||
import { Campaign } from '../../../services/campaign.model';
|
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
|
||||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||||
|
|
||||||
@@ -67,21 +66,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
|
|||||||
this.chapterName = currentChapter?.name ?? '';
|
this.chapterName = currentChapter?.name ?? '';
|
||||||
this.existingSceneCount = treeData.scenesByChapter[this.chapterId]?.length ?? 0;
|
this.existingSceneCount = treeData.scenesByChapter[this.chapterId]?.length ?? 0;
|
||||||
|
|
||||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
|
||||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.layoutService.show({
|
|
||||||
title: campaign.name,
|
|
||||||
items: buildCampaignTree(this.campaignId, treeData),
|
|
||||||
footerLabel: 'Toutes les campagnes',
|
|
||||||
createActions: [
|
|
||||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
|
||||||
],
|
|
||||||
globalItems,
|
|
||||||
globalBackLabel: 'Toutes les campagnes',
|
|
||||||
globalBackRoute: '/campaigns'
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +79,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
|
|||||||
order: this.existingSceneCount + 1,
|
order: this.existingSceneCount + 1,
|
||||||
icon: this.selectedIcon
|
icon: this.selectedIcon
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', created.id, 'edit']),
|
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', created.id]),
|
||||||
error: () => console.error('Erreur lors de la création de la scène')
|
error: () => console.error('Erreur lors de la création de la scène')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -104,6 +89,9 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,17 +9,18 @@ import { CampaignService } from '../../../services/campaign.service';
|
|||||||
import { CharacterService } from '../../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { NpcService } from '../../../services/npc.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { PageService } from '../../../services/page.service';
|
import { PageService } from '../../../services/page.service';
|
||||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
import { LayoutService } from '../../../services/layout.service';
|
||||||
import { PageTitleService } from '../../../services/page-title.service';
|
import { PageTitleService } from '../../../services/page-title.service';
|
||||||
import { Campaign, Scene, SceneBranch } from '../../../services/campaign.model';
|
import { Scene, SceneBranch } from '../../../services/campaign.model';
|
||||||
import { Page } from '../../../services/page.model';
|
import { Page } from '../../../services/page.model';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||||
import { ExpandableSectionComponent } from '../../../shared/expandable-section/expandable-section.component';
|
import { ExpandableSectionComponent } from '../../../shared/expandable-section/expandable-section.component';
|
||||||
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
|
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
|
||||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||||
|
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran de détail/modification d'une Scène.
|
* Écran de détail/modification d'une Scène.
|
||||||
@@ -75,7 +76,8 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
|||||||
private npcService: NpcService,
|
private npcService: NpcService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService,
|
||||||
|
private confirmDialog: ConfirmDialogService
|
||||||
) {
|
) {
|
||||||
this.form = this.fb.group({
|
this.form = this.fb.group({
|
||||||
name: ['', Validators.required],
|
name: ['', Validators.required],
|
||||||
@@ -155,21 +157,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
|||||||
enemies: scene.enemies ?? ''
|
enemies: scene.enemies ?? ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
|
||||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.layoutService.show({
|
|
||||||
title: campaign.name,
|
|
||||||
items: buildCampaignTree(this.campaignId, treeData),
|
|
||||||
footerLabel: 'Toutes les campagnes',
|
|
||||||
createActions: [
|
|
||||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
|
||||||
],
|
|
||||||
globalItems,
|
|
||||||
globalBackLabel: 'Toutes les campagnes',
|
|
||||||
globalBackRoute: '/campaigns'
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,11 +188,19 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
delete(): void {
|
delete(): void {
|
||||||
if (!confirm(`Supprimer la scène "${this.scene?.name}" ? Cette action est irréversible.`)) return;
|
this.confirmDialog.confirm({
|
||||||
|
title: 'Supprimer la scène',
|
||||||
|
message: `Supprimer la scène "${this.scene?.name}" ?`,
|
||||||
|
details: ['Cette action est irréversible.'],
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
this.campaignService.deleteScene(this.sceneId).subscribe({
|
this.campaignService.deleteScene(this.sceneId).subscribe({
|
||||||
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
||||||
error: () => console.error('Erreur lors de la suppression')
|
error: () => console.error('Erreur lors de la suppression')
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel(): void {
|
cancel(): void {
|
||||||
@@ -236,6 +232,9 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import { CampaignService } from '../../../services/campaign.service';
|
|||||||
import { CharacterService } from '../../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { NpcService } from '../../../services/npc.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { PageService } from '../../../services/page.service';
|
import { PageService } from '../../../services/page.service';
|
||||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
import { LayoutService } from '../../../services/layout.service';
|
||||||
import { PageTitleService } from '../../../services/page-title.service';
|
import { PageTitleService } from '../../../services/page-title.service';
|
||||||
import { Campaign, Scene } from '../../../services/campaign.model';
|
import { Scene } from '../../../services/campaign.model';
|
||||||
import { Page } from '../../../services/page.model';
|
import { Page } from '../../../services/page.model';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||||
|
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran de consultation d'une Scène (lecture seule).
|
* Écran de consultation d'une Scène (lecture seule).
|
||||||
@@ -49,7 +50,8 @@ export class SceneViewComponent implements OnInit, OnDestroy {
|
|||||||
private npcService: NpcService,
|
private npcService: NpcService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService,
|
||||||
|
private confirmDialog: ConfirmDialogService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -89,20 +91,7 @@ export class SceneViewComponent implements OnInit, OnDestroy {
|
|||||||
this.availablePages = pages;
|
this.availablePages = pages;
|
||||||
this.pageTitleService.set(scene.name);
|
this.pageTitleService.set(scene.name);
|
||||||
|
|
||||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
|
||||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
|
||||||
}));
|
|
||||||
this.layoutService.show({
|
|
||||||
title: campaign.name,
|
|
||||||
items: buildCampaignTree(this.campaignId, treeData),
|
|
||||||
footerLabel: 'Toutes les campagnes',
|
|
||||||
createActions: [
|
|
||||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
|
||||||
],
|
|
||||||
globalItems,
|
|
||||||
globalBackLabel: 'Toutes les campagnes',
|
|
||||||
globalBackRoute: '/campaigns'
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,16 +110,27 @@ export class SceneViewComponent implements OnInit, OnDestroy {
|
|||||||
deleteScene(): void {
|
deleteScene(): void {
|
||||||
if (!this.scene) return;
|
if (!this.scene) return;
|
||||||
const scene = this.scene;
|
const scene = this.scene;
|
||||||
if (!confirm(`Supprimer la scène "${scene.name}" ?\n\nCette action est irréversible.`)) return;
|
this.confirmDialog.confirm({
|
||||||
|
title: 'Supprimer la scène',
|
||||||
|
message: `Supprimer la scène "${scene.name}" ?`,
|
||||||
|
details: ['Cette action est irréversible.'],
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
this.campaignService.deleteScene(scene.id!).subscribe({
|
this.campaignService.deleteScene(scene.id!).subscribe({
|
||||||
next: () => this.router.navigate([
|
next: () => this.router.navigate([
|
||||||
'/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId
|
'/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId
|
||||||
]),
|
]),
|
||||||
error: () => console.error('Erreur lors de la suppression de la scène')
|
error: () => console.error('Erreur lors de la suppression de la scène')
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,18 +14,18 @@
|
|||||||
<div class="gse-form">
|
<div class="gse-form">
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Nom *</label>
|
<label for="gs-name">Nom *</label>
|
||||||
<input type="text" [(ngModel)]="name" name="name" placeholder="Ex: Nimble, D&D 5.1 SRD, Mon Homebrew..." />
|
<input id="gs-name" type="text" [(ngModel)]="name" name="name" placeholder="Ex: Nimble, D&D 5.1 SRD, Mon Homebrew..." />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Description courte</label>
|
<label for="gs-description">Description courte</label>
|
||||||
<textarea [(ngModel)]="description" name="description" rows="2" placeholder="En une ligne, de quoi parle ce système ?"></textarea>
|
<textarea id="gs-description" [(ngModel)]="description" name="description" rows="2" placeholder="En une ligne, de quoi parle ce système ?"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Auteur</label>
|
<label for="gs-author">Auteur</label>
|
||||||
<input type="text" [(ngModel)]="author" name="author" placeholder="Ex: Hasbro, Homebrew, moi-même..." />
|
<input id="gs-author" type="text" [(ngModel)]="author" name="author" placeholder="Ex: Hasbro, Homebrew, moi-même..." />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sections de règles -->
|
<!-- Sections de règles -->
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { LucideAngularModule, Dices, Plus, Pencil, Trash2 } from 'lucide-angular';
|
import { LucideAngularModule, Dices, Plus, Pencil, Trash2 } from 'lucide-angular';
|
||||||
import { GameSystemService } from '../services/game-system.service';
|
import { GameSystemService } from '../services/game-system.service';
|
||||||
|
import { LayoutService } from '../services/layout.service';
|
||||||
import { GameSystem } from '../services/game-system.model';
|
import { GameSystem } from '../services/game-system.model';
|
||||||
|
import { ConfirmDialogService } from '../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-game-systems',
|
selector: 'app-game-systems',
|
||||||
@@ -22,10 +24,15 @@ export class GameSystemsComponent implements OnInit {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private gameSystemService: GameSystemService
|
private gameSystemService: GameSystemService,
|
||||||
|
private confirmDialog: ConfirmDialogService,
|
||||||
|
private layoutService: LayoutService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
// Page racine : on s'assure de ne pas heriter de la sidebar d'une
|
||||||
|
// section precedente (cf. fix CampaignsComponent / LoreComponent).
|
||||||
|
this.layoutService.hide();
|
||||||
this.load();
|
this.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,10 +54,18 @@ export class GameSystemsComponent implements OnInit {
|
|||||||
delete(system: GameSystem, event: MouseEvent): void {
|
delete(system: GameSystem, event: MouseEvent): void {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (!system.id) return;
|
if (!system.id) return;
|
||||||
if (!confirm(`Supprimer le système "${system.name}" ? Les campagnes qui l'utilisent ne seront plus associées à aucun système.`)) return;
|
this.confirmDialog.confirm({
|
||||||
|
title: 'Supprimer le système',
|
||||||
|
message: `Supprimer le système "${system.name}" ?`,
|
||||||
|
details: ['Les campagnes qui l\'utilisent ne seront plus associées à aucun système.'],
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok || !system.id) return;
|
||||||
this.gameSystemService.delete(system.id).subscribe({
|
this.gameSystemService.delete(system.id).subscribe({
|
||||||
next: () => this.load(),
|
next: () => this.load(),
|
||||||
error: () => console.error('Erreur suppression GameSystem')
|
error: () => console.error('Erreur suppression GameSystem')
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Lore, LoreNode } from '../../services/lore.model';
|
|||||||
import { Page } from '../../services/page.model';
|
import { Page } from '../../services/page.model';
|
||||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||||
import { resolveIcon } from '../lore-icons';
|
import { resolveIcon } from '../lore-icons';
|
||||||
|
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vue "détail" d'un dossier : affiche son contenu (sous-dossiers + pages) et
|
* Vue "détail" d'un dossier : affiche son contenu (sous-dossiers + pages) et
|
||||||
@@ -52,7 +53,8 @@ export class FolderViewComponent implements OnInit, OnDestroy {
|
|||||||
private templateService: TemplateService,
|
private templateService: TemplateService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService,
|
||||||
|
private confirmDialog: ConfirmDialogService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -148,15 +150,20 @@ export class FolderViewComponent implements OnInit, OnDestroy {
|
|||||||
if (impact.folders > 0) parts.push(`${impact.folders} sous-dossier${impact.folders > 1 ? 's' : ''}`);
|
if (impact.folders > 0) parts.push(`${impact.folders} sous-dossier${impact.folders > 1 ? 's' : ''}`);
|
||||||
if (impact.pages > 0) parts.push(`${impact.pages} page${impact.pages > 1 ? 's' : ''}`);
|
if (impact.pages > 0) parts.push(`${impact.pages} page${impact.pages > 1 ? 's' : ''}`);
|
||||||
|
|
||||||
const lines = [`Supprimer le dossier "${node.name}" ?`];
|
const details: string[] = [];
|
||||||
if (parts.length) {
|
if (parts.length) {
|
||||||
lines.push('');
|
details.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
|
||||||
lines.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
|
|
||||||
}
|
}
|
||||||
lines.push('');
|
details.push('Cette action est irréversible.');
|
||||||
lines.push('Cette action est irréversible.');
|
|
||||||
|
|
||||||
if (!confirm(lines.join('\n'))) return;
|
this.confirmDialog.confirm({
|
||||||
|
title: 'Supprimer le dossier',
|
||||||
|
message: `Supprimer le dossier "${node.name}" ?`,
|
||||||
|
details,
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
this.loreService.deleteLoreNode(this.folderId).subscribe({
|
this.loreService.deleteLoreNode(this.folderId).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
// Remonte au dossier parent si présent, sinon au Lore.
|
// Remonte au dossier parent si présent, sinon au Lore.
|
||||||
@@ -168,12 +175,16 @@ export class FolderViewComponent implements OnInit, OnDestroy {
|
|||||||
},
|
},
|
||||||
error: () => console.error('Erreur lors de la suppression du dossier')
|
error: () => console.error('Erreur lors de la suppression du dossier')
|
||||||
});
|
});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
error: () => console.error('Impossible de récupérer les dépendances du dossier')
|
error: () => console.error('Impossible de récupérer les dépendances du dossier')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { LayoutService } from '../../services/layout.service';
|
|||||||
import { PageTitleService } from '../../services/page-title.service';
|
import { PageTitleService } from '../../services/page-title.service';
|
||||||
import { Lore, LoreNode } from '../../services/lore.model';
|
import { Lore, LoreNode } from '../../services/lore.model';
|
||||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||||
|
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-lore-detail',
|
selector: 'app-lore-detail',
|
||||||
@@ -42,7 +43,8 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
|
|||||||
private templateService: TemplateService,
|
private templateService: TemplateService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService,
|
||||||
|
private confirmDialog: ConfirmDialogService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -125,26 +127,31 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
|
|||||||
if (impact.pages > 0) deleted.push(`${impact.pages} page${impact.pages > 1 ? 's' : ''}`);
|
if (impact.pages > 0) deleted.push(`${impact.pages} page${impact.pages > 1 ? 's' : ''}`);
|
||||||
if (impact.templates > 0) deleted.push(`${impact.templates} template${impact.templates > 1 ? 's' : ''}`);
|
if (impact.templates > 0) deleted.push(`${impact.templates} template${impact.templates > 1 ? 's' : ''}`);
|
||||||
|
|
||||||
const lines = [`Supprimer définitivement le Lore "${lore.name}" ?`];
|
const details: string[] = [];
|
||||||
if (deleted.length) {
|
if (deleted.length) {
|
||||||
lines.push('');
|
details.push(`Cette action supprimera aussi : ${deleted.join(', ')}.`);
|
||||||
lines.push(`Cette action supprimera aussi : ${deleted.join(', ')}.`);
|
|
||||||
}
|
}
|
||||||
if (impact.detachedCampaigns > 0) {
|
if (impact.detachedCampaigns > 0) {
|
||||||
lines.push('');
|
details.push(
|
||||||
lines.push(
|
|
||||||
`${impact.detachedCampaigns} campagne${impact.detachedCampaigns > 1 ? 's' : ''} ${impact.detachedCampaigns > 1 ? 'seront conservées' : 'sera conservée'} ` +
|
`${impact.detachedCampaigns} campagne${impact.detachedCampaigns > 1 ? 's' : ''} ${impact.detachedCampaigns > 1 ? 'seront conservées' : 'sera conservée'} ` +
|
||||||
`mais ${impact.detachedCampaigns > 1 ? 'perdront' : 'perdra'} leur lien vers cet univers.`
|
`mais ${impact.detachedCampaigns > 1 ? 'perdront' : 'perdra'} leur lien vers cet univers.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
lines.push('');
|
details.push('Cette action est irréversible.');
|
||||||
lines.push('Cette action est irréversible.');
|
|
||||||
|
|
||||||
if (!confirm(lines.join('\n'))) return;
|
this.confirmDialog.confirm({
|
||||||
|
title: 'Supprimer le Lore',
|
||||||
|
message: `Supprimer définitivement le Lore "${lore.name}" ?`,
|
||||||
|
details,
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
this.loreService.deleteLore(lore.id!).subscribe({
|
this.loreService.deleteLore(lore.id!).subscribe({
|
||||||
next: () => this.router.navigate(['/lore']),
|
next: () => this.router.navigate(['/lore']),
|
||||||
error: () => console.error('Erreur lors de la suppression du Lore')
|
error: () => console.error('Erreur lors de la suppression du Lore')
|
||||||
});
|
});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
error: () => console.error('Impossible de récupérer les dépendances du Lore')
|
error: () => console.error('Impossible de récupérer les dépendances du Lore')
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ export class LoreNodeCreateComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,6 +147,9 @@ export class LoreNodeEditComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { LucideAngularModule, BookOpen, Folder, Plus } from 'lucide-angular';
|
import { LucideAngularModule, BookOpen, Folder, Plus } from 'lucide-angular';
|
||||||
import { LoreService } from '../services/lore.service';
|
import { LoreService } from '../services/lore.service';
|
||||||
|
import { LayoutService } from '../services/layout.service';
|
||||||
import { Lore } from '../services/lore.model';
|
import { Lore } from '../services/lore.model';
|
||||||
import { LoreCreateComponent } from './lore-create/lore-create.component';
|
import { LoreCreateComponent } from './lore-create/lore-create.component';
|
||||||
|
|
||||||
@@ -26,10 +27,15 @@ export class LoreComponent implements OnInit {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private loreService: LoreService,
|
private loreService: LoreService,
|
||||||
|
private layoutService: LayoutService,
|
||||||
private router: Router
|
private router: Router
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
// Liste racine de la section Lore : aucune sidebar secondaire ne doit
|
||||||
|
// subsister (sinon elle persiste depuis la section precedente — bug
|
||||||
|
// symetrique a celui de CampaignsComponent).
|
||||||
|
this.layoutService.hide();
|
||||||
this.loadLores();
|
this.loadLores();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,23 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="template-description">{{ t.description || '—' }}</p>
|
<p class="template-description">{{ t.description || '—' }}</p>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Carte "+" : sauvegarde le brouillon et part creer un nouveau template ;
|
||||||
|
template-create renverra ici via le mecanisme returnTo. -->
|
||||||
|
<a
|
||||||
|
class="template-card template-card-create"
|
||||||
|
[routerLink]="['/lore', loreId, 'templates', 'create']"
|
||||||
|
[queryParams]="{ returnTo: 'page-create' }"
|
||||||
|
(click)="saveDraft()"
|
||||||
|
title="Créer un nouveau template pour ce Lore">
|
||||||
|
<div class="template-card-head">
|
||||||
|
<lucide-icon [img]="Plus" [size]="16"></lucide-icon>
|
||||||
|
<span class="template-name">Créer un template</span>
|
||||||
|
</div>
|
||||||
|
<p class="template-description">
|
||||||
|
Vous reviendrez ici automatiquement, votre saisie sera conservée.
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ng-template #emptyTemplates>
|
<ng-template #emptyTemplates>
|
||||||
|
|||||||
@@ -116,6 +116,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Carte "+" pour creer un nouveau template depuis l'ecran de creation de page.
|
||||||
|
// Bordure pointillee + couleurs attenuees pour la distinguer visuellement des
|
||||||
|
// vraies cartes selectionnables (et indiquer que c'est une action, pas un
|
||||||
|
// element de donnees).
|
||||||
|
.template-card-create {
|
||||||
|
border-style: dashed !important;
|
||||||
|
border-color: #3a3a55 !important;
|
||||||
|
background: transparent !important;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
.template-card-head {
|
||||||
|
color: #d1a878;
|
||||||
|
.template-name { color: #d1a878; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #d1a878 !important;
|
||||||
|
background: rgba(209, 168, 120, 0.05) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.info-box {
|
.info-box {
|
||||||
background: #1a1a2e;
|
background: #1a1a2e;
|
||||||
border: 1px solid #2a2a3d;
|
border: 1px solid #2a2a3d;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
import { LucideAngularModule, FileText, Sparkles } from 'lucide-angular';
|
import { LucideAngularModule, FileText, Sparkles, Plus } from 'lucide-angular';
|
||||||
import { LoreService } from '../../services/lore.service';
|
import { LoreService } from '../../services/lore.service';
|
||||||
import { TemplateService } from '../../services/template.service';
|
import { TemplateService } from '../../services/template.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { PageService } from '../../services/page.service';
|
||||||
@@ -34,6 +34,7 @@ import { AiChatDrawerComponent, ChatPrimaryAction } from '../../shared/ai-chat-d
|
|||||||
export class PageCreateComponent implements OnInit, OnDestroy {
|
export class PageCreateComponent implements OnInit, OnDestroy {
|
||||||
readonly FileText = FileText;
|
readonly FileText = FileText;
|
||||||
readonly Sparkles = Sparkles;
|
readonly Sparkles = Sparkles;
|
||||||
|
readonly Plus = Plus;
|
||||||
|
|
||||||
form: FormGroup;
|
form: FormGroup;
|
||||||
loreId = '';
|
loreId = '';
|
||||||
@@ -117,6 +118,22 @@ export class PageCreateComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.restoreDraft();
|
this.restoreDraft();
|
||||||
|
|
||||||
|
// Retour depuis template-create avec selectTemplateId=ID : selectionne
|
||||||
|
// automatiquement le template fraichement cree (gagne sur restoreDraft).
|
||||||
|
const selectId = this.route.snapshot.queryParamMap.get('selectTemplateId');
|
||||||
|
if (selectId) {
|
||||||
|
const tpl = this.templates.find(t => t.id === selectId);
|
||||||
|
if (tpl) this.selectTemplate(tpl);
|
||||||
|
// On nettoie le query-param pour ne pas re-selectionner si la page
|
||||||
|
// est rechargee plus tard.
|
||||||
|
this.router.navigate([], {
|
||||||
|
relativeTo: this.route,
|
||||||
|
queryParams: { selectTemplateId: null },
|
||||||
|
queryParamsHandling: 'merge',
|
||||||
|
replaceUrl: true
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,6 +339,9 @@ Les clés du JSON doivent correspondre EXACTEMENT aux noms de champs indiqués.
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/bre
|
|||||||
import { AiChatDrawerComponent, ChatPrimaryAction } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
import { AiChatDrawerComponent, ChatPrimaryAction } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
||||||
import { Lore } from '../../services/lore.model';
|
import { Lore } from '../../services/lore.model';
|
||||||
|
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran d'édition d'une Page.
|
* Écran d'édition d'une Page.
|
||||||
@@ -90,7 +91,8 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
|||||||
private templateService: TemplateService,
|
private templateService: TemplateService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService,
|
||||||
|
private confirmDialog: ConfirmDialogService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -258,14 +260,24 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
delete(): void {
|
delete(): void {
|
||||||
if (!this.page) return;
|
if (!this.page) return;
|
||||||
if (!confirm(`Supprimer la page "${this.page.title}" ?`)) return;
|
this.confirmDialog.confirm({
|
||||||
|
title: 'Supprimer la page',
|
||||||
|
message: `Supprimer la page "${this.page.title}" ?`,
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok || !this.page) return;
|
||||||
this.pageService.delete(this.pageId).subscribe({
|
this.pageService.delete(this.pageId).subscribe({
|
||||||
next: () => this.router.navigate(['/lore', this.loreId]),
|
next: () => this.router.navigate(['/lore', this.loreId]),
|
||||||
error: () => console.error('Erreur lors de la suppression de la page')
|
error: () => console.error('Erreur lors de la suppression de la page')
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Page } from '../../services/page.model';
|
|||||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||||
import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.component';
|
import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.component';
|
||||||
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
||||||
|
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran de consultation d'une Page (mode lecture seule).
|
* Écran de consultation d'une Page (mode lecture seule).
|
||||||
@@ -51,7 +52,8 @@ export class PageViewComponent implements OnInit, OnDestroy {
|
|||||||
private templateService: TemplateService,
|
private templateService: TemplateService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService,
|
||||||
|
private confirmDialog: ConfirmDialogService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -129,7 +131,14 @@ export class PageViewComponent implements OnInit, OnDestroy {
|
|||||||
deletePage(): void {
|
deletePage(): void {
|
||||||
if (!this.page) return;
|
if (!this.page) return;
|
||||||
const page = this.page;
|
const page = this.page;
|
||||||
if (!confirm(`Supprimer la page "${page.title}" ?\n\nCette action est irréversible.`)) return;
|
this.confirmDialog.confirm({
|
||||||
|
title: 'Supprimer la page',
|
||||||
|
message: `Supprimer la page "${page.title}" ?`,
|
||||||
|
details: ['Cette action est irréversible.'],
|
||||||
|
confirmLabel: 'Supprimer',
|
||||||
|
variant: 'danger'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
this.pageService.delete(page.id!).subscribe({
|
this.pageService.delete(page.id!).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
if (page.nodeId) {
|
if (page.nodeId) {
|
||||||
@@ -140,9 +149,13 @@ export class PageViewComponent implements OnInit, OnDestroy {
|
|||||||
},
|
},
|
||||||
error: () => console.error('Erreur lors de la suppression de la page')
|
error: () => console.error('Erreur lors de la suppression de la page')
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,32 +176,40 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
|
|||||||
defaultNodeId: raw.defaultNodeId,
|
defaultNodeId: raw.defaultNodeId,
|
||||||
fields: this.fields
|
fields: this.fields
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: () => this.navigateBack(),
|
next: (created) => this.navigateBack(created.id ?? null),
|
||||||
error: () => console.error('Erreur lors de la création du template')
|
error: () => console.error('Erreur lors de la création du template')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel(): void {
|
cancel(): void {
|
||||||
this.navigateBack();
|
this.navigateBack(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redirige vers l'écran d'origine en dépilant le premier élément du query-param
|
* Redirige vers l'écran d'origine en dépilant le premier élément du query-param
|
||||||
* `returnTo` (pile de retours séparés par des virgules, ex : `page-create` ou
|
* `returnTo` (pile de retours séparés par des virgules, ex : `page-create` ou
|
||||||
* `template-create,page-create`). Sinon retombe sur la page détail du Lore.
|
* `template-create,page-create`). Sinon retombe sur la page détail du Lore.
|
||||||
|
*
|
||||||
|
* Si `createdTemplateId` est fourni (cas submit), on l'embarque dans le
|
||||||
|
* query-param `selectTemplateId` pour que page-create puisse pre-selectionner
|
||||||
|
* le template fraichement cree.
|
||||||
*/
|
*/
|
||||||
private navigateBack(): void {
|
private navigateBack(createdTemplateId: string | null): void {
|
||||||
const { next, rest } = popReturnTo(this.route.snapshot.queryParamMap.get('returnTo'));
|
const { next, rest } = popReturnTo(this.route.snapshot.queryParamMap.get('returnTo'));
|
||||||
if (next === 'page-create') {
|
if (next === 'page-create') {
|
||||||
this.router.navigate(['/lore', this.loreId, 'pages', 'create'], {
|
const queryParams: Record<string, string> = {};
|
||||||
queryParams: rest ? { returnTo: rest } : {}
|
if (rest) queryParams['returnTo'] = rest;
|
||||||
});
|
if (createdTemplateId) queryParams['selectTemplateId'] = createdTemplateId;
|
||||||
|
this.router.navigate(['/lore', this.loreId, 'pages', 'create'], { queryParams });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.router.navigate(['/lore', this.loreId]);
|
this.router.navigate(['/lore', this.loreId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||||
|
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||||
|
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||||
|
// disparition de la sidebar lors des navigations internes a la section.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user