Plusieurs ajouts :
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled

- Possibilité de configurer des lieux dans une scène : permet de configurer un donjon par exemple avec les pièces, les trésors par pièce, la narration..... Mise en place également de conditions permettant de conditionner le déblocage des quêtes.
- Possibilité de transformé un arc en instance non linéaire afin de faire un hub. Permet de jouer de préparer des campagnes type Dragon of Icespire peak plus facilement.
- Configuration de partie : chaque partie va contenir les séances, ce qui permettra de suivre le déblocage des conditions pour les quêtes.

Passage en 0.9.1-beta
This commit is contained in:
2026-06-03 15:44:48 +02:00
parent e3fc96a1bc
commit 504c4b7b6c
147 changed files with 5347 additions and 392 deletions

View File

@@ -17,6 +17,22 @@
/>
</div>
<div class="field">
<label>Structure de l'arc</label>
<div class="arc-type-choice">
<label class="arc-type-option" [class.selected]="form.get('type')?.value === 'LINEAR'">
<input type="radio" formControlName="type" value="LINEAR" />
<span class="arc-type-title">Linéaire</span>
<span class="arc-type-desc">Chapitres joués dans l'ordre — narration séquentielle classique.</span>
</label>
<label class="arc-type-option" [class.selected]="form.get('type')?.value === 'HUB'">
<input type="radio" formControlName="type" value="HUB" />
<span class="arc-type-title">Hub</span>
<span class="arc-type-desc">Quêtes parallèles débloquées par des conditions — type sandbox (ex : Phandalin / Icespire Peak).</span>
</label>
</div>
</div>
<div class="field">
<label for="arc-create-description">Description</label>
<textarea

View File

@@ -3,6 +3,44 @@
max-width: 640px;
}
.arc-type-choice {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.arc-type-option {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.85rem 0.95rem;
border: 1px solid var(--color-border, #e2e2e2);
border-radius: 10px;
cursor: pointer;
background: var(--color-surface, #fff);
transition: border-color 120ms ease, background 120ms ease;
&:hover { border-color: var(--color-primary, #2c6cd6); }
&.selected {
border-color: var(--color-primary, #2c6cd6);
background: rgba(66, 133, 244, 0.06);
}
input[type="radio"] { display: none; }
}
.arc-type-title {
font-weight: 600;
font-size: 0.95rem;
}
.arc-type-desc {
font-size: 0.78rem;
color: var(--color-text-muted, #666);
line-height: 1.3;
}
// Override local : titre en violet (pas en blanc comme le .page-header global).
.page-header h1 { color: #a5b4fc; }

View File

@@ -44,7 +44,9 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
) {
this.form = this.fb.group({
name: ['', Validators.required],
description: ['']
description: [''],
// Type structurel : LINEAR (séquentiel) par défaut, HUB (sandbox/quêtes parallèles).
type: ['LINEAR', Validators.required]
});
}
@@ -72,6 +74,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
description: this.form.value.description,
campaignId: this.campaignId,
order: this.existingArcCount + 1,
type: this.form.value.type,
icon: this.selectedIcon
}).subscribe({
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', created.id]),

View File

@@ -71,6 +71,22 @@
</textarea>
</div>
<div class="field">
<label>Structure de l'arc</label>
<div class="arc-type-choice">
<label class="arc-type-option" [class.selected]="form.get('type')?.value === 'LINEAR'">
<input type="radio" formControlName="type" value="LINEAR" />
<span class="arc-type-title">Linéaire</span>
<span class="arc-type-desc">Chapitres joués dans l'ordre — narration séquentielle.</span>
</label>
<label class="arc-type-option" [class.selected]="form.get('type')?.value === 'HUB'">
<input type="radio" formControlName="type" value="HUB" />
<span class="arc-type-title">Hub</span>
<span class="arc-type-desc">Quêtes parallèles débloquées par des conditions (sandbox).</span>
</label>
</div>
</div>
<div class="field">
<label>Icône</label>
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>

View File

@@ -3,6 +3,44 @@
max-width: 640px;
}
.arc-type-choice {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.arc-type-option {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.85rem 0.95rem;
border: 1px solid var(--color-border, #e2e2e2);
border-radius: 10px;
cursor: pointer;
background: var(--color-surface, #fff);
transition: border-color 120ms ease, background 120ms ease;
&:hover { border-color: var(--color-primary, #2c6cd6); }
&.selected {
border-color: var(--color-primary, #2c6cd6);
background: rgba(66, 133, 244, 0.06);
}
input[type="radio"] { display: none; }
}
.arc-type-title {
font-weight: 600;
font-size: 0.95rem;
}
.arc-type-desc {
font-size: 0.78rem;
color: var(--color-text-muted, #666);
line-height: 1.3;
}
// Header local : titre à gauche, actions (Assistant IA) à droite.
.page-header {
display: flex;

View File

@@ -85,6 +85,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
this.form = this.fb.group({
name: ['', Validators.required],
description: [''],
type: ['LINEAR', Validators.required],
themes: [''],
stakes: [''],
gmNotes: [''],
@@ -137,6 +138,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
this.form.patchValue({
name: arc.name,
description: arc.description ?? '',
type: arc.type ?? 'LINEAR',
themes: arc.themes ?? '',
stakes: arc.stakes ?? '',
gmNotes: arc.gmNotes ?? '',
@@ -155,6 +157,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
description: this.form.value.description,
campaignId: this.campaignId,
order: this.arc.order ?? 1,
type: this.form.value.type,
themes: this.form.value.themes,
stakes: this.form.value.stakes,
gmNotes: this.form.value.gmNotes,

View File

@@ -6,7 +6,9 @@
<lucide-icon *ngIf="arc.icon" [img]="resolveCampaignIcon(arc.icon)" [size]="22" class="title-icon"></lucide-icon>
{{ arc.name }}
</h1>
<p class="view-subtitle">Arc narratif</p>
<p class="view-subtitle">
{{ arc.type === 'HUB' ? 'Arc en hub (quêtes non linéaires)' : 'Arc narratif' }}
</p>
</div>
<div class="view-actions">
<button type="button" class="btn-primary" (click)="editMode()">
@@ -31,6 +33,40 @@
<app-image-gallery [imageIds]="arc.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
</section>
<!-- Vue Hub (scénario) : liste les quêtes et leurs conditions, sans statut.
Le statut effectif et les actions de jeu vivent dans l'écran d'une Partie. -->
<section class="view-section" *ngIf="arc.type === 'HUB'">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Quêtes du hub (scénario)</h2>
<p class="view-section-empty" *ngIf="hubQuests.length === 0">
Aucune quête pour ce hub. Créez un chapitre pour ajouter une première quête.
</p>
<div class="hub-quest-grid" *ngIf="hubQuests.length > 0">
<button type="button"
class="hub-quest-card"
*ngFor="let q of hubQuests"
(click)="openQuest(q)">
<div class="hub-quest-card-head">
<span class="hub-quest-card-icon" *ngIf="q.icon">
<lucide-icon [img]="resolveCampaignIcon(q.icon)" [size]="18"></lucide-icon>
</span>
<span class="hub-quest-card-name">{{ q.name }}</span>
</div>
<p class="hub-quest-card-desc" *ngIf="q.description?.trim()">{{ q.description }}</p>
<div class="hub-quest-card-locked-hint" *ngIf="(q.prerequisites?.length ?? 0) > 0">
<lucide-icon [img]="AlertCircle" [size]="13"></lucide-icon>
<span>{{ q.prerequisites!.length }} condition(s) de déblocage</span>
<ul class="hub-quest-card-prereq-list">
<li *ngFor="let p of q.prerequisites">{{ describePrerequisite(p) }}</li>
</ul>
</div>
</button>
</div>
</section>
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">📜</span> Synopsis</h2>
<p class="view-section-body" *ngIf="arc.description?.trim(); else emptyDesc">{{ arc.description }}</p>

View File

@@ -1 +1,112 @@
// Styles partagés via styles/_view.scss
// === Vue Hub ===
.hub-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
.view-section-title { margin: 0; }
}
.hub-quest-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.85rem;
}
.hub-quest-card {
position: relative;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.85rem 0.9rem;
border: 1px solid var(--color-border, #e2e2e2);
border-radius: 10px;
background: var(--color-surface, #fff);
text-align: left;
cursor: pointer;
transition: border-color 120ms ease, transform 120ms ease, box-shadow 120ms ease;
min-height: 110px;
&:hover {
border-color: var(--color-primary, #2c6cd6);
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.06);
}
&.locked {
opacity: 0.7;
background: rgba(128, 128, 128, 0.04);
border-style: dashed;
&:hover {
// Pas d'illusion d'interactivité pour les quêtes verrouillées
transform: none;
border-color: var(--color-border, #e2e2e2);
box-shadow: none;
}
}
}
.hub-quest-card-head {
display: flex;
align-items: center;
gap: 0.45rem;
}
.hub-quest-card-icon {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--color-primary, #2c6cd6);
}
.hub-quest-card-name {
font-weight: 600;
font-size: 0.95rem;
line-height: 1.2;
}
.hub-quest-card-desc {
font-size: 0.82rem;
color: var(--color-text-muted, #666);
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.hub-quest-card-footer {
margin-top: auto;
}
.hub-quest-card-locked-hint {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-top: 0.4rem;
padding-top: 0.4rem;
border-top: 1px dashed rgba(128, 128, 128, 0.25);
font-size: 0.72rem;
color: var(--color-text-muted, #777);
> lucide-icon,
> span {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
}
.hub-quest-card-prereq-list {
list-style: disc;
padding-left: 1rem;
margin: 0.15rem 0 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}

View File

@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
import { LucideAngularModule, Pencil, Trash2, AlertCircle } from 'lucide-angular';
import { resolveCampaignIcon } from '../../campaign-icons';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
@@ -11,7 +11,7 @@ import { NpcService } from '../../../services/npc.service';
import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Arc } from '../../../services/campaign.model';
import { Arc, Chapter, Prerequisite } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
@@ -32,12 +32,22 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
export class ArcViewComponent implements OnInit, OnDestroy {
readonly Pencil = Pencil;
readonly Trash2 = Trash2;
readonly AlertCircle = AlertCircle;
readonly resolveCampaignIcon = resolveCampaignIcon;
campaignId = '';
arcId = '';
arc: Arc | null = null;
/** Chapitres de l'arc courant — exploités pour le rendu HUB (grille de quêtes). */
hubQuests: Chapter[] = [];
/**
* Indexe les chapitres de toute la campagne par id pour résoudre les libellés
* des prérequis QUEST_COMPLETED quand on les affiche dans les tooltips de verrouillage.
*/
private allChaptersById: Record<string, Chapter> = {};
/** ID du Lore associé à la campagne (null si pas d'univers lié). */
loreId: string | null = null;
/** Pages du Lore — pour résoudre relatedPageIds en titres. */
@@ -85,10 +95,42 @@ export class ArcViewComponent implements OnInit, OnDestroy {
this.availablePages = pages;
this.pageTitleService.set(arc.name);
// Quêtes du Hub : chapitres de l'arc courant, triés par order puis par nom.
this.hubQuests = [...(treeData.chaptersByArc[this.arcId] ?? [])].sort((a, b) => {
const oa = a.order ?? 0;
const ob = b.order ?? 0;
if (oa !== ob) return oa - ob;
return a.name.localeCompare(b.name, 'fr', { numeric: true, sensitivity: 'base' });
});
// Index global pour résoudre les noms de quêtes référencées par les prérequis.
this.allChaptersById = {};
Object.values(treeData.chaptersByArc).forEach(list =>
list.forEach(c => { if (c.id) this.allChaptersById[c.id] = c; })
);
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
});
}
/** Construit un libellé lisible pour un prérequis (tooltip de verrouillage). */
describePrerequisite(p: Prerequisite): string {
switch (p.kind) {
case 'QUEST_COMPLETED':
return `Quête « ${this.allChaptersById[p.questId]?.name ?? '?'} » terminée`;
case 'SESSION_REACHED':
return `Session ${p.minSessionNumber} atteinte`;
case 'FLAG_SET':
return `Fait : ${p.flagName}`;
}
}
openQuest(q: Chapter): void {
if (!q.id) return;
this.router.navigate([
'/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', q.id
]);
}
titleOfRelated(pageId: string): string {
return this.availablePages.find(p => p.id === pageId)?.title ?? '(page supprimée)';
}

View File

@@ -30,9 +30,12 @@ export function loadCampaignTreeData(
characterService: CharacterService,
npcService: NpcService
): Observable<CampaignTreeData> {
// Note refonte Playthrough : les PJ appartiennent désormais à une Partie,
// pas à la campagne — on ne les charge plus ici (les vues qui les affichent
// doivent passer par PlaythroughService et appeler characterService.getByPlaythrough).
return forkJoin({
arcs: service.getArcs(campaignId),
characters: characterService.getByCampaign(campaignId),
characters: of([] as Character[]),
npcs: npcService.getByCampaign(campaignId)
}).pipe(
switchMap(({ arcs, characters, npcs }) => {

View File

@@ -196,60 +196,38 @@
</div>
</section>
<!-- ============ Sessions de jeu ============ -->
<section class="detail-section sessions-section" *ngIf="!editing">
<!-- ============ Parties (Playthroughs) ============ -->
<section class="detail-section playthroughs-section" *ngIf="!editing">
<div class="section-header">
<h2>
<lucide-icon [img]="Dices" [size]="18"></lucide-icon>
Sessions de jeu
Mes parties
</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 class="btn-add" [disabled]="newPlaythroughInFlight" (click)="createPlaythrough()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouvelle partie
</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 class="playthroughs-grid" *ngIf="playthroughs.length > 0">
<div class="playthrough-card"
*ngFor="let p of playthroughs"
(click)="openPlaythrough(p)">
<lucide-icon [img]="Dices" [size]="22" class="playthrough-icon"></lucide-icon>
<div class="playthrough-info">
<span class="playthrough-name">{{ p.name }}</span>
<span class="playthrough-meta" *ngIf="p.description">{{ p.description }}</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 class="empty-state empty-state--compact" *ngIf="playthroughs.length === 0">
<p>Aucune partie. Créez-en une pour démarrer une session avec une table.</p>
</div>
</section>
<!-- Sessions retirées : elles vivent désormais dans une Partie.
Les faits narratifs sont définis dans les conditions de chaque quête
(chapter-edit > « Conditions de déblocage »). -->
</div>

View File

@@ -1,3 +1,58 @@
.section-hint {
margin: -0.25rem 0 0.85rem;
font-size: 0.85rem;
color: var(--color-text-muted, #666);
max-width: 70ch;
line-height: 1.4;
}
// ==== Parties (Playthroughs) ====
.playthroughs-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.75rem;
}
.playthrough-card {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.85rem 1rem;
border: 1px solid var(--color-border, #e2e2e2);
border-radius: 10px;
background: var(--color-surface, #fff);
cursor: pointer;
transition: border-color 120ms ease, transform 120ms ease, box-shadow 120ms ease;
&:hover {
border-color: var(--color-primary, #2c6cd6);
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.06);
}
}
.playthrough-icon { color: var(--color-primary, #2c6cd6); flex: 0 0 auto; }
.playthrough-info {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.playthrough-name {
font-weight: 600;
font-size: 0.95rem;
}
.playthrough-meta {
font-size: 0.78rem;
color: var(--color-text-muted, #777);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.campaign-detail {
padding: 2.5rem 2rem;
display: flex;

View File

@@ -13,6 +13,8 @@ import { GameSystem } from '../../../services/game-system.model';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { SessionService } from '../../../services/session.service';
import { PlaythroughService } from '../../../services/playthrough.service';
import { Playthrough } from '../../../services/campaign.model';
import { Session } from '../../../services/session.model';
import { Character } from '../../../services/character.model';
import { Npc } from '../../../services/npc.model';
@@ -69,6 +71,11 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
/** Indicateur de lancement en cours pour éviter les double-clics. */
startingSession = false;
/** Parties (Playthroughs) de cette campagne. */
playthroughs: Playthrough[] = [];
/** Partie par défaut (1re) — fallback pour les actions session/PJ tant que l'UI Playthrough n'est pas finie. */
defaultPlaythroughId: string | null = null;
/** Mode édition inline. */
editing = false;
editName = '';
@@ -92,6 +99,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
private characterService: CharacterService,
private npcService: NpcService,
private sessionService: SessionService,
private playthroughService: PlaythroughService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService
@@ -109,11 +117,14 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [] } as CampaignTreeData))
)
),
playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[])))
}))
).subscribe(({ campaign, allCampaigns, treeData }) => {
).subscribe(({ campaign, allCampaigns, treeData, playthroughs }) => {
this.campaign = campaign;
this.editing = false;
this.playthroughs = playthroughs;
this.defaultPlaythroughId = playthroughs.length > 0 ? playthroughs[0].id! : null;
this.loadLinkedLore(campaign);
this.loadLinkedGameSystem(campaign);
this.loadCharacters(campaign.id!);
@@ -145,10 +156,13 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [] } as CampaignTreeData))
)
}).subscribe(({ campaign, allCampaigns, treeData }) => {
),
playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[])))
}).subscribe(({ campaign, allCampaigns, treeData, playthroughs }) => {
this.campaign = campaign;
this.editing = false;
this.playthroughs = playthroughs;
this.defaultPlaythroughId = playthroughs.length > 0 ? playthroughs[0].id! : null;
this.loadLinkedLore(campaign);
this.loadLinkedGameSystem(campaign);
this.loadCharacters(campaign.id!);
@@ -186,9 +200,13 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
).subscribe(gs => this.linkedGameSystem = gs);
}
/** Charge les fiches de personnages (PJ) de la campagne. */
private loadCharacters(campaignId: string): void {
this.characterService.getByCampaign(campaignId).pipe(
/** Charge les PJ de la Partie par défaut (refonte Playthrough). */
private loadCharacters(_campaignId: string): void {
if (!this.defaultPlaythroughId) {
this.characters = [];
return;
}
this.characterService.getByPlaythrough(this.defaultPlaythroughId).pipe(
catchError(() => of([] as Character[]))
).subscribe(list => this.characters = list);
}
@@ -200,55 +218,33 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
).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);
// Sessions retirées de cette vue (vivent dans Playthrough Detail).
private loadSessions(_campaignId: string): void { this.sessions = []; }
this.sessionService.getActiveSession().pipe(
catchError(() => of(null))
).subscribe(active => this.activeSessionGlobal = active);
// ─────────────── Playthroughs (Parties) ───────────────
openPlaythrough(p: Playthrough): void {
this.router.navigate(['/campaigns', this.campaign?.id, 'playthroughs', p.id]);
}
/** 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]);
/** Crée une nouvelle Partie avec un nom par défaut, puis y navigue. */
newPlaythroughInFlight = false;
createPlaythrough(): void {
if (!this.campaign || this.newPlaythroughInFlight) return;
this.newPlaythroughInFlight = true;
const defaultName = `Nouvelle partie ${this.playthroughs.length + 1}`;
this.playthroughService.create({
campaignId: this.campaign.id!,
name: defaultName
}).subscribe({
next: created => {
this.newPlaythroughInFlight = false;
this.router.navigate(['/campaigns', this.campaign?.id, 'playthroughs', created.id]);
},
error: () => {
this.startingSession = false;
console.error('Erreur lors du lancement de la session');
}
error: () => { this.newPlaythroughInFlight = false; }
});
}
openSession(session: Session): void {
this.router.navigate(['/sessions', session.id]);
}
createCharacter(): void {
if (!this.campaign) return;
this.router.navigate(['/campaigns', this.campaign.id, 'characters', 'create']);
@@ -444,7 +440,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
if (impact.arcs > 0) parts.push(`${impact.arcs} arc${impact.arcs > 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.characters > 0) parts.push(`${impact.characters} personnage${impact.characters > 1 ? 's' : ''}`);
if (impact.playthroughs > 0) parts.push(`${impact.playthroughs} partie${impact.playthroughs > 1 ? 's' : ''} (avec ses PJ, sessions, faits)`);
const details: string[] = [];
if (parts.length) details.push(`Sera aussi supprime : ${parts.join(', ')}.`);

View File

@@ -26,6 +26,22 @@
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
<!-- Section Hub : conditions de déblocage (scénario). Visible si l'arc parent est HUB. -->
<div class="hub-section" *ngIf="parentArc?.type === 'HUB'">
<h2 class="hub-section-title">🗺️ Conditions de déblocage de la quête</h2>
<small class="field-hint">
Définition du scénario : toutes les conditions doivent être remplies (ET) pour
que la quête soit débloquée dans une Partie. La progression en cours et l'état
des faits se gèrent dans l'écran de la Partie, pas ici.
</small>
<app-prerequisite-editor
[prerequisites]="prerequisites"
[availableQuests]="availableQuests"
[availableFlags]="availableFlagNames"
(prerequisitesChange)="onPrerequisitesChange($event)">
</app-prerequisite-editor>
</div>
<!-- Illustrations (galerie editable, rendu editorial) -->
<div class="field">
<label>Illustrations</label>

View File

@@ -3,6 +3,57 @@
max-width: 640px;
}
// === Section Hub : prérequis + progression ===
.hub-section {
border: 1px solid rgba(66, 133, 244, 0.25);
background: rgba(66, 133, 244, 0.03);
border-radius: 12px;
padding: 1rem 1.1rem 1.25rem;
margin-bottom: 1rem;
}
.hub-section-title {
margin: 0 0 0.85rem;
font-size: 1rem;
font-weight: 600;
color: var(--color-primary, #2c6cd6);
}
.progression-choice {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.progression-option {
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border, #e2e2e2);
border-radius: 8px;
text-align: center;
font-size: 0.88rem;
cursor: pointer;
background: var(--color-surface, #fff);
transition: border-color 120ms ease, background 120ms ease;
&:hover { border-color: var(--color-primary, #2c6cd6); }
&.selected {
border-color: var(--color-primary, #2c6cd6);
background: rgba(66, 133, 244, 0.08);
font-weight: 600;
}
input[type="radio"] { display: none; }
}
.hub-flags-panel {
margin-top: 0.75rem;
padding: 0.85rem;
border: 1px dashed var(--color-border, #d8d8d8);
border-radius: 8px;
background: var(--color-surface, #fff);
}
// Header local : titre à gauche, actions (Assistant IA) à droite.
.page-header {
display: flex;

View File

@@ -11,27 +11,38 @@ import { NpcService } from '../../../services/npc.service';
import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Chapter } from '../../../services/campaign.model';
import { Chapter, Prerequisite, Arc } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
import { PrerequisiteEditorComponent } from '../../../shared/prerequisite-editor/prerequisite-editor.component';
import { CampaignFlagService } from '../../../services/campaign-flag.service';
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
/**
* Écran de détail/modification d'un Chapitre.
* Route : /campaigns/:campaignId/arcs/:arcId/chapters/:chapterId
* Écran d'édition d'un Chapitre. Donnée de SCÉNARIO uniquement.
*
* Inclut le picker de pages Lore (B2 cross-context) si la campagne parente
* est associée à un Lore.
* Depuis Playthrough : la progression et le toggle des flags se font dans le
* contexte d'une Partie (voir Playthrough Detail). On garde ici uniquement les
* prérequis (conditions de déblocage), qui font partie du scénario.
*/
@Component({
selector: 'app-chapter-edit',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent],
imports: [
CommonModule,
ReactiveFormsModule,
LucideAngularModule,
LoreLinkPickerComponent,
AiChatDrawerComponent,
ImageGalleryComponent,
IconPickerComponent,
PrerequisiteEditorComponent
],
templateUrl: './chapter-edit.component.html',
styleUrls: ['./chapter-edit.component.scss']
})
@@ -41,7 +52,6 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
selectedIcon: string | null = null;
/** État drawer chat IA (b5.7 — intégration Campagne). */
chatOpen = false;
readonly chatQuickSuggestions = [
'Propose des objectifs clairs pour les joueurs dans ce chapitre',
@@ -63,6 +73,18 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
illustrationImageIds: string[] = [];
mapImageIds: string[] = [];
/** Prérequis (donnée de scénario). */
prerequisites: Prerequisite[] = [];
/** Quêtes candidates pour QUEST_COMPLETED (chapitres de la campagne, sauf celui-ci). */
availableQuests: Chapter[] = [];
/** Faits déclarés au niveau Campagne (autocomplete FLAG_SET). */
availableFlagNames: string[] = [];
/** L'arc parent — pour conditionner la section Hub (prérequis pertinents). */
parentArc: Arc | null = null;
constructor(
private fb: FormBuilder,
private route: ActivatedRoute,
@@ -73,7 +95,8 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService
private confirmDialog: ConfirmDialogService,
private campaignFlagService: CampaignFlagService
) {
this.form = this.fb.group({
name: ['', Validators.required],
@@ -85,9 +108,6 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
}
ngOnInit(): void {
// On s'abonne à paramMap plutôt que de lire snapshot une fois : Angular
// réutilise le composant quand on navigue entre chapitres frères via
// l'arbre (même route pattern), et ngOnInit ne se relance pas.
this.route.paramMap.subscribe(pm => {
const newCampaignId = pm.get('campaignId')!;
const newArcId = pm.get('arcId')!;
@@ -124,6 +144,21 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
this.selectedIcon = chapter.icon ?? null;
this.illustrationImageIds = [...(chapter.illustrationImageIds ?? [])];
this.mapImageIds = [...(chapter.mapImageIds ?? [])];
this.prerequisites = [...(chapter.prerequisites ?? [])];
const allChapters: Chapter[] = [];
Object.values(treeData.chaptersByArc).forEach(list => allChapters.push(...list));
this.availableQuests = allChapters.filter(c => c.id !== this.chapterId);
this.parentArc = treeData.arcs.find(a => a.id === this.arcId) ?? null;
// Autocomplete des FLAG_SET : les noms de faits déjà référencés ailleurs
// dans la campagne (déduit des autres quêtes).
this.campaignFlagService.listReferenced(this.campaignId).subscribe({
next: names => { this.availableFlagNames = names; }
});
this.form.patchValue({
name: chapter.name,
description: chapter.description ?? '',
@@ -136,6 +171,10 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
});
}
onPrerequisitesChange(next: Prerequisite[]): void {
this.prerequisites = next;
}
submit(): void {
if (this.form.invalid || !this.chapter) return;
this.campaignService.updateChapter(this.chapterId, {
@@ -146,6 +185,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
gmNotes: this.form.value.gmNotes,
playerObjectives: this.form.value.playerObjectives,
narrativeStakes: this.form.value.narrativeStakes,
prerequisites: this.prerequisites,
relatedPageIds: this.relatedPageIds,
illustrationImageIds: this.illustrationImageIds,
mapImageIds: this.mapImageIds,
@@ -176,10 +216,5 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]);
}
ngOnDestroy(): void {
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement / la
// disparition de la sidebar lors des navigations internes a la section.
}
ngOnDestroy(): void {}
}

View File

@@ -6,7 +6,7 @@
<lucide-icon *ngIf="chapter.icon" [img]="resolveCampaignIcon(chapter.icon)" [size]="22" class="title-icon"></lucide-icon>
{{ chapter.name }}
</h1>
<p class="view-subtitle">Chapitre</p>
<p class="view-subtitle">{{ parentArc?.type === 'HUB' ? 'Quête (Hub)' : 'Chapitre' }}</p>
</div>
<div class="view-actions">
<button type="button" class="btn-secondary" (click)="openGraph()"
@@ -25,6 +25,31 @@
</div>
</header>
<!-- Conditions de déblocage (donnée de scénario, read-only). Toujours visible si Arc HUB. -->
<section class="view-section" *ngIf="parentArc?.type === 'HUB'">
<h2 class="view-section-title"><span class="view-section-icon">🔒</span> Conditions de déblocage</h2>
<ng-container *ngIf="(chapter.prerequisites?.length ?? 0) > 0; else noPrereqs">
<ul class="view-section-body">
<li *ngFor="let p of chapter.prerequisites">{{ describePrerequisite(p) }}</li>
</ul>
<small class="view-section-empty">
Pendant une partie, la quête se débloque dès que toutes ces conditions sont remplies.
Le toggle des faits se fait dans l'écran « Faits » de la Partie.
</small>
</ng-container>
<ng-template #noPrereqs>
<p class="view-section-empty">
Aucune condition — cette quête est disponible dès le début dans chaque Partie.
</p>
<p class="view-section-empty">
Cliquez sur <strong>Modifier</strong> pour ajouter des conditions
(« quête précédente terminée », « à partir de la session N », « quand un fait est vrai »).
</p>
</ng-template>
</section>
<!-- Illustrations (rendu editorial magazine) -->
<section class="view-section" *ngIf="(chapter.illustrationImageIds?.length ?? 0) > 0">
<app-image-gallery [imageIds]="chapter.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>

View File

@@ -1 +1,35 @@
// Styles partagés via styles/_view.scss
.quest-status-bar {
border: 1px solid rgba(66, 133, 244, 0.25);
background: rgba(66, 133, 244, 0.04);
}
.quest-status-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.quest-status-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.quest-prereq-summary {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px dashed rgba(66, 133, 244, 0.2);
font-size: 0.85rem;
color: var(--color-text-muted, #555);
ul {
margin: 0.3rem 0 0;
padding-left: 1.2rem;
li { margin-bottom: 0.15rem; }
}
}

View File

@@ -11,7 +11,7 @@ import { NpcService } from '../../../services/npc.service';
import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Chapter } from '../../../services/campaign.model';
import { Chapter, Prerequisite, Arc } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
@@ -38,9 +38,11 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
arcId = '';
chapterId = '';
chapter: Chapter | null = null;
parentArc: Arc | null = null;
loreId: string | null = null;
availablePages: Page[] = [];
private allChaptersById: Record<string, Chapter> = {};
constructor(
private route: ActivatedRoute,
@@ -88,10 +90,28 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
this.availablePages = pages;
this.pageTitleService.set(chapter.name);
// Arc parent (pour conditionner les sections Hub) + index des quêtes par id.
this.parentArc = treeData.arcs.find(a => a.id === this.arcId) ?? null;
this.allChaptersById = {};
Object.values(treeData.chaptersByArc).forEach(list =>
list.forEach(c => { if (c.id) this.allChaptersById[c.id] = c; })
);
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
});
}
describePrerequisite(p: Prerequisite): string {
switch (p.kind) {
case 'QUEST_COMPLETED':
return `Quête « ${this.allChaptersById[p.questId]?.name ?? '?'} » terminée`;
case 'SESSION_REACHED':
return `Session ${p.minSessionNumber} atteinte`;
case 'FLAG_SET':
return `Fait : ${p.flagName}`;
}
}
titleOfRelated(pageId: string): string {
return this.availablePages.find(p => p.id === pageId)?.title ?? '(page supprimée)';
}

View File

@@ -5,6 +5,7 @@ import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lucide-angular';
import { CharacterService } from '../../../services/character.service';
import { CampaignService } from '../../../services/campaign.service';
import { PlaythroughService } from '../../../services/playthrough.service';
import { GameSystemService } from '../../../services/game-system.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { TemplateField } from '../../../services/template.model';
@@ -48,6 +49,8 @@ export class CharacterEditComponent implements OnInit {
toggleChat(): void { this.chatOpen = !this.chatOpen; }
campaignId: string | null = null;
/** Partie cible — déduite du 1er Playthrough de la campagne. */
playthroughId: string | null = null;
characterId: string | null = null;
name = '';
@@ -64,6 +67,7 @@ export class CharacterEditComponent implements OnInit {
private router: Router,
private service: CharacterService,
private campaignService: CampaignService,
private playthroughService: PlaythroughService,
private gameSystemService: GameSystemService,
private campaignSidebar: CampaignSidebarService,
private confirmDialog: ConfirmDialogService
@@ -77,6 +81,10 @@ export class CharacterEditComponent implements OnInit {
if (this.campaignId) {
this.loadTemplateForCampaign(this.campaignId);
this.campaignSidebar.show(this.campaignId);
// Résolution Partie par défaut (1er Playthrough) — nécessaire pour create/update.
this.playthroughService.listByCampaign(this.campaignId).subscribe({
next: list => { this.playthroughId = list.length > 0 ? list[0].id! : null; }
});
}
if (this.characterId) {
@@ -113,7 +121,7 @@ export class CharacterEditComponent implements OnInit {
submit(): void {
if (!this.name.trim() || !this.campaignId) return;
if (!this.name.trim() || !this.campaignId || !this.playthroughId) return;
const payload = {
name: this.name.trim(),
portraitImageId: this.portraitImageId,
@@ -121,7 +129,7 @@ export class CharacterEditComponent implements OnInit {
values: this.values,
imageValues: this.imageValues,
keyValueValues: this.keyValueValues,
campaignId: this.campaignId
playthroughId: this.playthroughId
};
const isCreation = !this.characterId;
const req = this.characterId

View File

@@ -0,0 +1,66 @@
<div class="playthrough-page" *ngIf="playthrough">
<header class="page-header">
<button type="button" class="btn-secondary" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour
</button>
<div class="header-info">
<h1>{{ playthrough.name }}</h1>
<p class="subtitle" *ngIf="playthrough.description">{{ playthrough.description }}</p>
</div>
<div class="header-actions">
<button type="button" class="btn-danger" (click)="delete()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</header>
<!-- Bloc action principal : démarrer ou reprendre la session -->
<section class="play-action">
<button type="button" class="btn-primary big"
*ngIf="!activeOnThis"
[disabled]="startingSession"
(click)="startSession()">
<lucide-icon [img]="Play" [size]="16"></lucide-icon>
Lancer une session
</button>
<button type="button" class="btn-primary big"
*ngIf="activeOnThis"
(click)="openSession(activeOnThis)">
<lucide-icon [img]="Play" [size]="16"></lucide-icon>
Reprendre la session en cours
</button>
<button type="button" class="btn-secondary" (click)="openFlags()">
<lucide-icon [img]="Flag" [size]="14"></lucide-icon>
Faits de la partie
</button>
</section>
<!-- PJ -->
<section class="block">
<h2><lucide-icon [img]="Users" [size]="18"></lucide-icon> Personnages joueurs</h2>
<p class="empty" *ngIf="characters.length === 0">Aucun PJ pour cette partie.</p>
<ul class="character-list" *ngIf="characters.length > 0">
<li *ngFor="let c of characters">
<a [routerLink]="['/campaigns', campaignId, 'characters', c.id]">{{ c.name }}</a>
</li>
</ul>
</section>
<!-- Sessions -->
<section class="block">
<h2>Sessions</h2>
<p class="empty" *ngIf="sessions.length === 0">Aucune session encore. Lancez la première !</p>
<ul class="session-list" *ngIf="sessions.length > 0">
<li *ngFor="let s of sessions" (click)="openSession(s)">
<span class="session-name">{{ s.name }}</span>
<span class="session-status" [class.active]="s.active">{{ s.active ? 'En cours' : 'Terminée' }}</span>
</li>
</ul>
</section>
</div>

View File

@@ -0,0 +1,109 @@
.playthrough-page {
padding: 2.5rem 2rem;
max-width: 880px;
}
.page-header {
display: flex;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1.5rem;
h1 { margin: 0 0 0.2rem; font-size: 1.6rem; }
}
.header-info { flex: 1; }
.subtitle {
margin: 0;
font-size: 0.9rem;
color: var(--color-text-muted, #666);
}
.play-action {
display: flex;
gap: 0.75rem;
align-items: center;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.btn-primary.big,
.btn-secondary.big {
padding: 0.75rem 1.25rem;
font-size: 0.95rem;
}
.block {
margin-bottom: 1.75rem;
h2 {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.05rem;
margin: 0 0 0.7rem;
}
}
.empty {
font-size: 0.88rem;
color: var(--color-text-muted, #777);
font-style: italic;
}
.character-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
a {
display: inline-block;
padding: 0.35rem 0.7rem;
border: 1px solid var(--color-border, #e2e2e2);
border-radius: 999px;
text-decoration: none;
color: inherit;
&:hover { border-color: var(--color-primary, #2c6cd6); }
}
}
.session-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 0.85rem;
border: 1px solid var(--color-border, #e2e2e2);
border-radius: 8px;
cursor: pointer;
transition: border-color 120ms ease;
&:hover { border-color: var(--color-primary, #2c6cd6); }
}
}
.session-status {
font-size: 0.78rem;
padding: 0.15rem 0.55rem;
border-radius: 999px;
background: rgba(128, 128, 128, 0.1);
color: var(--color-text-muted, #666);
&.active {
background: rgba(52, 168, 83, 0.15);
color: #2f7a47;
font-weight: 600;
}
}

View File

@@ -0,0 +1,145 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { LucideAngularModule, ArrowLeft, Play, Flag, Users, Trash2, Pencil } from 'lucide-angular';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { PlaythroughService } from '../../../services/playthrough.service';
import { SessionService } from '../../../services/session.service';
import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Playthrough } from '../../../services/campaign.model';
import { Session } from '../../../services/session.model';
import { Character } from '../../../services/character.model';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
/**
* Vue détail d'une Partie (Playthrough).
* Minimal MVP — affiche les infos, le lien vers les faits, la liste des sessions
* et les PJ de cette Partie.
*/
@Component({
selector: 'app-playthrough-detail',
standalone: true,
imports: [CommonModule, RouterModule, LucideAngularModule],
templateUrl: './playthrough-detail.component.html',
styleUrls: ['./playthrough-detail.component.scss']
})
export class PlaythroughDetailComponent implements OnInit, OnDestroy {
readonly ArrowLeft = ArrowLeft;
readonly Play = Play;
readonly Flag = Flag;
readonly Users = Users;
readonly Trash2 = Trash2;
readonly Pencil = Pencil;
campaignId = '';
playthroughId = '';
playthrough: Playthrough | null = null;
sessions: Session[] = [];
characters: Character[] = [];
/** Session active de CETTE Partie (null si aucune). Plus de check global. */
activeOnThis: Session | null = null;
startingSession = false;
constructor(
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private playthroughService: PlaythroughService,
private sessionService: SessionService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService
) {}
ngOnInit(): void {
this.route.paramMap.subscribe(pm => {
const cid = pm.get('campaignId')!;
const pid = pm.get('playthroughId')!;
if (cid !== this.campaignId || pid !== this.playthroughId) {
this.campaignId = cid;
this.playthroughId = pid;
this.load();
}
});
}
private load(): void {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService),
playthrough: this.playthroughService.getById(this.playthroughId),
sessions: this.sessionService.getSessions(this.playthroughId).pipe(catchError(() => of([] as Session[]))),
characters: this.characterService.getByPlaythrough(this.playthroughId).pipe(catchError(() => of([] as Character[]))),
activeOnThis: this.sessionService.getActiveByPlaythrough(this.playthroughId).pipe(catchError(() => of(null)))
}).subscribe(({ campaign, allCampaigns, treeData, playthrough, sessions, characters, activeOnThis }) => {
this.playthrough = playthrough;
this.sessions = sessions;
this.characters = characters;
this.activeOnThis = activeOnThis;
this.pageTitleService.set(`${playthrough.name}${campaign.name}`);
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
});
}
startSession(): void {
if (this.startingSession || this.activeOnThis) return;
this.startingSession = true;
this.sessionService.startSession(this.playthroughId).subscribe({
next: s => { this.startingSession = false; this.router.navigate(['/sessions', s.id]); },
error: () => { this.startingSession = false; }
});
}
openSession(s: Session): void {
this.router.navigate(['/sessions', s.id]);
}
openFlags(): void {
this.router.navigate(['/campaigns', this.campaignId, 'playthroughs', this.playthroughId, 'flags']);
}
back(): void {
this.router.navigate(['/campaigns', this.campaignId]);
}
delete(): void {
if (!this.playthrough) return;
this.playthroughService.deletionImpact(this.playthroughId).subscribe({
next: impact => {
const parts: string[] = [];
if (impact.sessions > 0) parts.push(`${impact.sessions} session(s)`);
if (impact.characters > 0) parts.push(`${impact.characters} PJ`);
if (impact.flags > 0) parts.push(`${impact.flags} fait(s)`);
if (impact.progressions > 0) parts.push(`${impact.progressions} progression(s) de quête`);
const details: string[] = [];
if (parts.length) details.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
details.push('Cette action est irréversible.');
this.confirmDialog.confirm({
title: 'Supprimer la Partie',
message: `Supprimer "${this.playthrough?.name}" ?`,
details,
confirmLabel: 'Supprimer',
variant: 'danger'
}).then(ok => {
if (!ok) return;
this.playthroughService.delete(this.playthroughId).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId])
});
});
}
});
}
ngOnDestroy(): void {}
}

View File

@@ -0,0 +1,20 @@
<div class="flags-page" *ngIf="playthrough">
<header class="page-header">
<button type="button" class="btn-secondary" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour
</button>
<div>
<h1>Faits — {{ playthrough.name }}</h1>
<p class="subtitle">
Faits booléens propres à cette partie. Toggle-les en jeu pour débloquer
les quêtes qui en dépendent.
</p>
</div>
</header>
<app-playthrough-flags-manager
[campaignId]="campaignId"
[playthroughId]="playthroughId">
</app-playthrough-flags-manager>
</div>

View File

@@ -0,0 +1,24 @@
.flags-page {
padding: 2.5rem 2rem;
max-width: 720px;
}
.page-header {
display: flex;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1.5rem;
h1 {
margin: 0 0 0.25rem;
font-size: 1.5rem;
}
}
.subtitle {
margin: 0;
font-size: 0.88rem;
color: var(--color-text-muted, #666);
max-width: 60ch;
line-height: 1.4;
}

View File

@@ -0,0 +1,75 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule, ArrowLeft } from 'lucide-angular';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { PlaythroughService } from '../../../services/playthrough.service';
import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Playthrough } from '../../../services/campaign.model';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { PlaythroughFlagsManagerComponent } from '../../../shared/playthrough-flags-manager/playthrough-flags-manager.component';
/**
* Page dédiée aux "Faits" d'une Partie.
* Route : /campaigns/:campaignId/playthroughs/:playthroughId/flags
*/
@Component({
selector: 'app-playthrough-flags-page',
standalone: true,
imports: [CommonModule, RouterModule, LucideAngularModule, PlaythroughFlagsManagerComponent],
templateUrl: './playthrough-flags-page.component.html',
styleUrls: ['./playthrough-flags-page.component.scss']
})
export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy {
readonly ArrowLeft = ArrowLeft;
campaignId = '';
playthroughId = '';
playthrough: Playthrough | null = null;
constructor(
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private playthroughService: PlaythroughService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
) {}
ngOnInit(): void {
this.route.paramMap.subscribe(pm => {
const cid = pm.get('campaignId')!;
const pid = pm.get('playthroughId')!;
if (cid !== this.campaignId || pid !== this.playthroughId) {
this.campaignId = cid;
this.playthroughId = pid;
this.load();
}
});
}
private load(): void {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService),
playthrough: this.playthroughService.getById(this.playthroughId)
}).subscribe(({ campaign, allCampaigns, treeData, playthrough }) => {
this.playthrough = playthrough;
this.pageTitleService.set(`${playthrough.name} — Faits`);
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
});
}
back(): void {
this.router.navigate(['/campaigns', this.campaignId]);
}
ngOnDestroy(): void {}
}

View File

@@ -231,6 +231,18 @@
</small>
</div>
<!-- Lieu explorable : pièces / donjon -->
<app-expandable-section title="Lieu explorable (donjon, crypte…)" icon="🏰" [initiallyOpen]="rooms.length > 0">
<small class="field-hint">
Si cette scène représente un lieu à parcourir pièce par pièce, ajoutez-les ici.
La scène bascule alors en mode « donjon » côté affichage.
</small>
<app-rooms-editor
[rooms]="rooms"
(roomsChange)="onRoomsChange($event)">
</app-rooms-editor>
</app-expandable-section>
</form>
</div>

View File

@@ -11,7 +11,7 @@ import { NpcService } from '../../../services/npc.service';
import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Scene, SceneBranch } from '../../../services/campaign.model';
import { Scene, SceneBranch, Room } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { ExpandableSectionComponent } from '../../../shared/expandable-section/expandable-section.component';
@@ -19,6 +19,7 @@ import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-l
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
import { RoomsEditorComponent } from '../../../shared/rooms-editor/rooms-editor.component';
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
@@ -29,7 +30,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
@Component({
selector: 'app-scene-edit',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent],
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent, RoomsEditorComponent],
templateUrl: './scene-edit.component.html',
styleUrls: ['./scene-edit.component.scss']
})
@@ -67,6 +68,11 @@ export class SceneEditComponent implements OnInit, OnDestroy {
/** Branches narratives (état local mutable, persisté au submit). */
branches: SceneBranch[] = [];
/** Pièces du lieu explorable (état local, persisté au submit). */
rooms: Room[] = [];
onRoomsChange(next: Room[]): void { this.rooms = next; }
constructor(
private fb: FormBuilder,
private route: ActivatedRoute,
@@ -144,6 +150,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
this.mapImageIds = [...(scene.mapImageIds ?? [])];
this.siblingScenes = chapterScenes.filter(s => s.id !== this.sceneId);
this.branches = (scene.branches ?? []).map(b => ({ ...b }));
this.rooms = (scene.rooms ?? []).map(r => ({ ...r, branches: [...(r.branches ?? [])] }));
this.form.patchValue({
name: scene.name,
description: scene.description ?? '',
@@ -180,6 +187,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
illustrationImageIds: this.illustrationImageIds,
mapImageIds: this.mapImageIds,
branches: this.branches,
rooms: this.rooms,
icon: this.selectedIcon
}).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', this.sceneId]),

View File

@@ -100,4 +100,48 @@
</div>
</section>
<!-- Lieu explorable : pièces (mode donjon) -->
<section class="view-section" *ngIf="(scene.rooms?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🏰</span> Pièces du lieu</h2>
<div class="rooms-readonly">
<article class="room-readonly" *ngFor="let r of scene.rooms; let i = index">
<header class="room-readonly-head">
<span class="room-readonly-index">#{{ i + 1 }}</span>
<span class="room-readonly-name">{{ r.name }}</span>
<span class="room-readonly-floor" *ngIf="r.floor !== null && r.floor !== undefined">
Étage {{ r.floor }}
</span>
</header>
<p class="room-readonly-desc" *ngIf="r.description?.trim()">{{ r.description }}</p>
<div class="room-readonly-grid">
<div *ngIf="r.enemies?.trim()">
<strong>⚔️ Ennemis</strong>
<p>{{ r.enemies }}</p>
</div>
<div *ngIf="r.loot?.trim()">
<strong>💰 Loot</strong>
<p>{{ r.loot }}</p>
</div>
<div *ngIf="r.traps?.trim()">
<strong>⚠️ Pièges</strong>
<p>{{ r.traps }}</p>
</div>
<div *ngIf="r.gmNotes?.trim()" class="room-readonly-private">
<strong>🔒 Notes MJ</strong>
<p>{{ r.gmNotes }}</p>
</div>
</div>
<div class="room-readonly-branches" *ngIf="(r.branches?.length ?? 0) > 0">
<strong>→ Sorties</strong>
<ul>
<li *ngFor="let b of r.branches">
<em>{{ b.label }}</em> → {{ roomNameById(scene, b.targetRoomId) }}
<span class="branch-cond" *ngIf="b.condition?.trim()">(si : {{ b.condition }})</span>
</li>
</ul>
</div>
</article>
</div>
</section>
</div>

View File

@@ -1 +1,84 @@
// Styles partagés via styles/_view.scss
.rooms-readonly {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.room-readonly {
border: 1px solid var(--color-border, #e2e2e2);
border-radius: 10px;
padding: 0.85rem 1rem;
background: var(--color-surface, #fff);
}
.room-readonly-head {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.4rem;
}
.room-readonly-index {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 0.78rem;
color: var(--color-text-muted, #777);
}
.room-readonly-name {
font-weight: 600;
font-size: 1rem;
flex: 1;
}
.room-readonly-floor {
font-size: 0.78rem;
padding: 0.15rem 0.55rem;
border-radius: 999px;
background: rgba(66, 133, 244, 0.1);
color: #2c6cd6;
}
.room-readonly-desc {
margin: 0 0 0.6rem;
font-size: 0.9rem;
line-height: 1.5;
color: var(--color-text, #333);
white-space: pre-wrap;
}
.room-readonly-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.7rem;
margin-bottom: 0.5rem;
> div {
strong { display: block; font-size: 0.82rem; margin-bottom: 0.2rem; color: var(--color-text-muted, #555); }
p { margin: 0; font-size: 0.88rem; white-space: pre-wrap; }
}
}
.room-readonly-private {
background: rgba(192, 57, 43, 0.05);
padding: 0.4rem 0.55rem;
border-radius: 6px;
border-left: 3px solid rgba(192, 57, 43, 0.4);
}
.room-readonly-branches {
margin-top: 0.45rem;
padding-top: 0.45rem;
border-top: 1px dashed var(--color-border, #ececec);
font-size: 0.85rem;
strong { display: block; font-size: 0.82rem; margin-bottom: 0.15rem; color: var(--color-text-muted, #555); }
ul { margin: 0; padding-left: 1.1rem; }
li { margin-bottom: 0.1rem; }
}
.branch-cond {
color: var(--color-text-muted, #777);
font-style: italic;
}

View File

@@ -99,6 +99,11 @@ export class SceneViewComponent implements OnInit, OnDestroy {
return this.availablePages.find(p => p.id === pageId)?.title ?? '(page supprimée)';
}
/** Résout le nom d'une pièce cible (pour afficher les sorties inter-pièces). */
roomNameById(scene: Scene | null, roomId: string): string {
return scene?.rooms?.find(r => r.id === roomId)?.name ?? '(pièce supprimée)';
}
editMode(): void {
this.router.navigate([
'/campaigns', this.campaignId, 'arcs', this.arcId,