Changement sur le Readme

Ajout d'une partie spécifique pour des PNJ dans la partie campagne
This commit is contained in:
2026-04-27 15:48:04 +02:00
parent aaebeaa547
commit 389392fd1d
80 changed files with 1771 additions and 719 deletions

View File

@@ -0,0 +1,45 @@
<div class="chapter-create-page">
<div class="page-header">
<h1>Créer un nouveau chapitre</h1>
<p class="arc-ref" *ngIf="arcName">Arc : {{ arcName }}</p>
</div>
<form [formGroup]="form" (ngSubmit)="submit()" class="chapter-form">
<div class="field">
<label for="chapter-create-name">Nom du chapitre *</label>
<input
id="chapter-create-name"
type="text"
formControlName="name"
placeholder="Ex: Chapitre 1: Les Disparitions"
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
/>
</div>
<div class="field">
<label for="chapter-create-description">Description</label>
<textarea
id="chapter-create-description"
formControlName="description"
placeholder="Décrivez ce chapitre..."
rows="5">
</textarea>
</div>
<div class="field">
<label>Icône</label>
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
Créer le chapitre
</button>
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,21 @@
.chapter-create-page {
padding: 2.5rem 2rem;
max-width: 640px;
}
// Overrides locaux :
// - titre en violet (au lieu de blanc comme le .page-header global)
// - sous-titre .arc-ref spécifique à cet écran (référence à l'arc parent)
.page-header {
h1 { color: #a5b4fc; }
.arc-ref { color: #6b7280; font-size: 0.85rem; margin: 0; }
}
.chapter-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
// Le bouton "Créer" prend toute la largeur restante.
.btn-primary { flex: 1; }

View File

@@ -0,0 +1,107 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule } from 'lucide-angular';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { LayoutService, GlobalItem } from '../../../services/layout.service';
import { Campaign } from '../../../services/campaign.model';
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
/**
* Écran de création d'un nouveau chapitre rattaché à un arc.
* Route : /campaigns/:campaignId/arcs/:arcId/chapters/create
*/
@Component({
selector: 'app-chapter-create',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, IconPickerComponent],
templateUrl: './chapter-create.component.html',
styleUrls: ['./chapter-create.component.scss']
})
export class ChapterCreateComponent implements OnInit, OnDestroy {
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
selectedIcon: string | null = null;
form: FormGroup;
campaignId = '';
arcId = '';
arcName = '';
private existingChapterCount = 0;
constructor(
private fb: FormBuilder,
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private layoutService: LayoutService
) {
this.form = this.fb.group({
name: ['', Validators.required],
description: ['']
});
}
ngOnInit(): void {
this.campaignId = this.route.snapshot.paramMap.get('campaignId')!;
this.arcId = this.route.snapshot.paramMap.get('arcId')!;
this.loadLayout();
}
private loadLayout(): void {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
}).subscribe(({ campaign, allCampaigns, treeData }) => {
const currentArc = treeData.arcs.find(a => a.id === this.arcId);
this.arcName = currentArc?.name ?? '';
this.existingChapterCount = treeData.chaptersByArc[this.arcId]?.length ?? 0;
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
}));
this.layoutService.show({
title: campaign.name,
items: buildCampaignTree(this.campaignId, treeData),
footerLabel: 'Toutes les campagnes',
createActions: [
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
],
globalItems,
globalBackLabel: 'Toutes les campagnes',
globalBackRoute: '/campaigns'
});
});
}
submit(): void {
if (this.form.invalid) return;
this.campaignService.createChapter({
name: this.form.value.name,
description: this.form.value.description,
arcId: this.arcId,
order: this.existingChapterCount + 1,
icon: this.selectedIcon
}).subscribe({
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', created.id]),
error: () => console.error('Erreur lors de la création du chapitre')
});
}
cancel(): void {
this.router.navigate(['/campaigns', this.campaignId]);
}
ngOnDestroy(): void {
this.layoutService.hide();
}
}

View File

@@ -0,0 +1,145 @@
<div class="edit-page">
<div class="page-header">
<div>
<h1>{{ chapter?.name || 'Chapitre' }}</h1>
<p class="subtitle">Chapitre</p>
</div>
<div class="header-actions">
<button type="button" class="btn-ai"
(click)="toggleChat()"
[class.active]="chatOpen"
title="Ouvrir l'Assistant IA pour dialoguer autour de ce chapitre">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
<button type="button" class="btn-danger" (click)="delete()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
<button type="button" class="btn-primary" (click)="submit()" [disabled]="form.invalid">
Sauvegarder
</button>
</div>
</div>
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
<!-- Illustrations (galerie editable, rendu editorial) -->
<div class="field">
<label>Illustrations</label>
<app-image-gallery
[imageIds]="illustrationImageIds"
[editable]="true"
[layout]="'EDITORIAL'"
(imageIdsChange)="illustrationImageIds = $event">
</app-image-gallery>
<small class="field-hint">Portraits, ambiances, scenes marquantes du chapitre.</small>
</div>
<!-- Cartes & plans -->
<div class="field">
<label>Cartes &amp; plans</label>
<app-image-gallery
[imageIds]="mapImageIds"
[editable]="true"
[layout]="'MAPS'"
(imageIdsChange)="mapImageIds = $event">
</app-image-gallery>
<small class="field-hint">Cartes regionales, plans de donjon, schemas utiles a la table.</small>
</div>
<div class="field">
<label for="chapter-edit-name">Titre du chapitre *</label>
<input
id="chapter-edit-name"
type="text"
formControlName="name"
placeholder="Ex: Chapitre 1: Les Disparitions"
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
/>
</div>
<div class="field">
<label for="chapter-edit-description">Synopsis du chapitre</label>
<textarea
id="chapter-edit-description"
formControlName="description"
placeholder="Décrivez brièvement ce qui se passe dans ce chapitre..."
rows="5">
</textarea>
</div>
<div class="field">
<label>Icône</label>
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
</div>
<div class="field">
<label for="chapter-edit-gm-notes">Notes du Maître de Jeu</label>
<textarea
id="chapter-edit-gm-notes"
formControlName="gmNotes"
placeholder="Vos notes privées sur le déroulement du chapitre, les événements clés, les rebondissements..."
rows="6">
</textarea>
<small class="field-hint">Ces notes sont privées et ne seront pas exportées vers FoundryVTT.</small>
</div>
<div class="field-row">
<div class="field">
<label for="chapter-edit-player-objectives">Objectifs des joueurs</label>
<textarea
id="chapter-edit-player-objectives"
formControlName="playerObjectives"
placeholder="Que doivent accomplir les joueurs dans ce chapitre ?"
rows="4">
</textarea>
</div>
<div class="field">
<label for="chapter-edit-narrative-stakes">Enjeux narratifs</label>
<textarea
id="chapter-edit-narrative-stakes"
formControlName="narrativeStakes"
placeholder="Quels sont les enjeux dramatiques ?"
rows="4">
</textarea>
</div>
</div>
<!-- ===== Pages Lore associées (B2 cross-context) ===== -->
<div class="field" *ngIf="loreId">
<label>Pages Lore associées</label>
<app-lore-link-picker
[value]="relatedPageIds"
[availablePages]="availablePages"
[loreId]="loreId"
(valueChange)="relatedPageIds = $event">
</app-lore-link-picker>
<small class="field-hint">
Liez ce chapitre à des PNJ, lieux ou éléments du Lore qui y apparaissent.
</small>
</div>
<div class="field" *ngIf="!loreId">
<small class="field-hint">
💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne
pour pouvoir lier ce chapitre à des pages du Lore.
</small>
</div>
</form>
</div>
<!-- Drawer chat IA (hors .edit-page pour couvrir le viewport à droite) -->
<app-ai-chat-drawer
[campaignId]="campaignId"
entityType="chapter"
[entityId]="chapterId"
[isOpen]="chatOpen"
welcomeMessage="Je vois ce chapitre. Demande-moi d'étoffer ses objectifs, ses enjeux ou sa scène d'ouverture."
[quickSuggestions]="chatQuickSuggestions"
(close)="chatOpen = false">
</app-ai-chat-drawer>

View File

@@ -0,0 +1,31 @@
.edit-page {
padding: 2.5rem 2rem;
max-width: 640px;
}
// Header local : titre à gauche, actions (Assistant IA) à droite.
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
.header-actions {
display: flex;
gap: 0.5rem;
}
}
.edit-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
// Override local : bouton Supprimer poussé à droite (voir _buttons.scss pour la base).
.btn-danger {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin-left: auto;
}

View File

@@ -0,0 +1,186 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { PageService } from '../../../services/page.service';
import { LayoutService, GlobalItem } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Campaign, Chapter } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model';
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
/**
* Écran de détail/modification d'un Chapitre.
* Route : /campaigns/:campaignId/arcs/:arcId/chapters/:chapterId
*
* Inclut le picker de pages Lore (B2 cross-context) si la campagne parente
* est associée à un Lore.
*/
@Component({
selector: 'app-chapter-edit',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent],
templateUrl: './chapter-edit.component.html',
styleUrls: ['./chapter-edit.component.scss']
})
export class ChapterEditComponent implements OnInit, OnDestroy {
readonly Trash2 = Trash2;
readonly Sparkles = Sparkles;
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
selectedIcon: string | null = null;
/** État drawer chat IA (b5.7 — intégration Campagne). */
chatOpen = false;
readonly chatQuickSuggestions = [
'Propose des objectifs clairs pour les joueurs dans ce chapitre',
'Imagine 2 tensions narratives qui relancent l\'intérêt en milieu de chapitre',
'Suggère une scène d\'ouverture marquante'
];
toggleChat(): void { this.chatOpen = !this.chatOpen; }
form: FormGroup;
campaignId = '';
arcId = '';
chapterId = '';
chapter: Chapter | null = null;
availablePages: Page[] = [];
loreId: string | null = null;
relatedPageIds: string[] = [];
illustrationImageIds: string[] = [];
mapImageIds: string[] = [];
constructor(
private fb: FormBuilder,
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
) {
this.form = this.fb.group({
name: ['', Validators.required],
description: [''],
gmNotes: [''],
playerObjectives: [''],
narrativeStakes: ['']
});
}
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')!;
const newChapterId = pm.get('chapterId')!;
if (newChapterId !== this.chapterId ||
newArcId !== this.arcId ||
newCampaignId !== this.campaignId) {
this.campaignId = newCampaignId;
this.arcId = newArcId;
this.chapterId = newChapterId;
this.loadAll();
}
});
}
private loadAll(): void {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
chapter: this.campaignService.getChapterById(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
}).pipe(
switchMap(data => {
const lid = data.campaign.loreId ?? null;
const pages$ = lid ? this.pageService.getByLoreId(lid) : of([] as Page[]);
return pages$.pipe(switchMap(pages => of({ ...data, pages, loreId: lid })));
})
).subscribe(({ campaign, allCampaigns, chapter, treeData, pages, loreId }) => {
this.chapter = chapter;
this.pageTitleService.set(chapter.name);
this.loreId = loreId;
this.availablePages = pages;
this.relatedPageIds = [...(chapter.relatedPageIds ?? [])];
this.selectedIcon = chapter.icon ?? null;
this.illustrationImageIds = [...(chapter.illustrationImageIds ?? [])];
this.mapImageIds = [...(chapter.mapImageIds ?? [])];
this.form.patchValue({
name: chapter.name,
description: chapter.description ?? '',
gmNotes: chapter.gmNotes ?? '',
playerObjectives: chapter.playerObjectives ?? '',
narrativeStakes: chapter.narrativeStakes ?? ''
});
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
}));
this.layoutService.show({
title: campaign.name,
items: buildCampaignTree(this.campaignId, treeData),
footerLabel: 'Toutes les campagnes',
createActions: [
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
],
globalItems,
globalBackLabel: 'Toutes les campagnes',
globalBackRoute: '/campaigns'
});
});
}
submit(): void {
if (this.form.invalid || !this.chapter) return;
this.campaignService.updateChapter(this.chapterId, {
name: this.form.value.name,
description: this.form.value.description,
arcId: this.arcId,
order: this.chapter.order ?? 1,
gmNotes: this.form.value.gmNotes,
playerObjectives: this.form.value.playerObjectives,
narrativeStakes: this.form.value.narrativeStakes,
relatedPageIds: this.relatedPageIds,
illustrationImageIds: this.illustrationImageIds,
mapImageIds: this.mapImageIds,
icon: this.selectedIcon
}).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]),
error: () => console.error('Erreur lors de la sauvegarde')
});
}
delete(): void {
if (!confirm(`Supprimer le chapitre "${this.chapter?.name}" ? Cette action est irréversible.`)) return;
this.campaignService.deleteChapter(this.chapterId).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId]),
error: () => console.error('Erreur lors de la suppression')
});
}
cancel(): void {
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]);
}
ngOnDestroy(): void {
this.layoutService.hide();
}
}

View File

@@ -0,0 +1,74 @@
<div class="graph-page">
<div class="page-header">
<div>
<h1>{{ chapter?.name || 'Chapitre' }} — Carte</h1>
<p class="subtitle">Organigramme des scènes et de leurs branches narratives</p>
</div>
<button type="button" class="btn-secondary" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour au chapitre
</button>
</div>
<div class="graph-empty" *ngIf="scenes.length === 0">
<p>Ce chapitre n'a aucune scène. Créez-en pour voir apparaître la carte.</p>
</div>
<div class="graph-container" *ngIf="scenes.length > 0">
<svg #svgEl
[attr.width]="svgWidth" [attr.height]="svgHeight"
class="graph-svg"
(pointermove)="onPointerMove($event)"
(pointerup)="onPointerUp($event)"
(pointercancel)="onPointerUp($event)">
<defs>
<marker id="arrowhead" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#b8c0cc" />
</marker>
</defs>
<g class="edges">
<g class="edge" *ngFor="let edge of edges">
<line [attr.x1]="edge.x1" [attr.y1]="edge.y1"
[attr.x2]="edge.x2" [attr.y2]="edge.y2"
stroke="#b8c0cc" stroke-width="2"
marker-end="url(#arrowhead)" />
<text *ngIf="edge.label"
[attr.x]="edge.labelX"
[attr.y]="edge.labelY"
text-anchor="middle"
class="edge-label"
[class.dragging]="draggingLabelKey === edge.key"
(pointerdown)="onLabelPointerDown($event, edge)">
{{ edge.label }}
</text>
</g>
</g>
<g class="nodes">
<g class="node"
[class.dragging]="draggingId === node.id"
*ngFor="let node of nodes"
(pointerdown)="onPointerDown($event, node)">
<title>{{ node.name }}</title>
<rect [attr.x]="node.x" [attr.y]="node.y"
[attr.width]="NODE_WIDTH" [attr.height]="NODE_HEIGHT"
rx="8" ry="8" class="node-box" />
<text [attr.x]="node.x + NODE_WIDTH / 2"
[attr.y]="node.y + NODE_HEIGHT / 2 + 5"
text-anchor="middle"
class="node-label">
{{ node.displayName }}
</text>
</g>
</g>
</svg>
<small class="graph-hint">
💡 Cliquez sur une scène pour l'ouvrir, ou glissez-la pour réorganiser la carte. Les scènes non reliées au point d'entrée (scène d'ordre 1) apparaissent en bas.
</small>
</div>
</div>

View File

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

View File

@@ -0,0 +1,376 @@
import { Component, OnInit, OnDestroy, ElementRef, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin } from 'rxjs';
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 { LayoutService, GlobalItem } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Campaign, Chapter, Scene } from '../../../services/campaign.model';
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
interface GraphNode { id: string; name: string; displayName: string; x: number; y: number; }
interface GraphEdge { key: string; label: string; x1: number; y1: number; x2: number; y2: number; labelX: number; labelY: number; }
/**
* Vue graphique d'un chapitre : organigramme des scènes et branches narratives.
* Layout custom (BFS par niveaux) en SVG — évite une dépendance lourde type ngx-graph.
*/
@Component({
selector: 'app-chapter-graph',
standalone: true,
imports: [CommonModule, RouterModule, LucideAngularModule],
templateUrl: './chapter-graph.component.html',
styleUrls: ['./chapter-graph.component.scss']
})
export class ChapterGraphComponent implements OnInit, OnDestroy {
readonly ArrowLeft = ArrowLeft;
campaignId = '';
arcId = '';
chapterId = '';
chapter: Chapter | null = null;
scenes: Scene[] = [];
nodes: GraphNode[] = [];
edges: GraphEdge[] = [];
readonly NODE_WIDTH = 220;
readonly NODE_HEIGHT = 64;
readonly H_SPACING = 50;
readonly V_SPACING = 90;
readonly MAX_LABEL_CHARS = 26;
svgWidth = 600;
svgHeight = 400;
@ViewChild('svgEl') svgEl?: ElementRef<SVGSVGElement>;
// Etat de drag : id du noeud manipule, offset entre le pointeur et le coin
// haut-gauche du noeud (en coords SVG), et flag indiquant qu'un mouvement
// significatif a eu lieu (pour distinguer clic vs glisser).
draggingId: string | null = null;
draggingLabelKey: string | null = null;
private dragOffsetX = 0;
private dragOffsetY = 0;
private dragMoved = false;
private readonly DRAG_THRESHOLD = 4;
// Decalage manuel applique a chaque label d'arete, indexe par cle stable
// (sourceId|targetId|branchIdx). Persiste a travers les recalculs d'aretes
// pour que le label suive son arete quand on deplace un noeud, tout en
// conservant le repositionnement manuel de l'utilisateur.
private labelOffsets = new Map<string, { dx: number; dy: number }>();
constructor(
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
) {}
ngOnInit(): void {
this.route.paramMap.subscribe(pm => {
this.campaignId = pm.get('campaignId')!;
this.arcId = pm.get('arcId')!;
this.chapterId = pm.get('chapterId')!;
this.load();
});
}
private load(): void {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
chapter: this.campaignService.getChapterById(this.chapterId),
scenes: this.campaignService.getScenes(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
}).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => {
this.chapter = chapter;
this.scenes = scenes;
this.pageTitleService.set(`${chapter.name} — Carte`);
this.buildGraph();
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
}));
this.layoutService.show({
title: campaign.name,
items: buildCampaignTree(this.campaignId, treeData),
footerLabel: 'Toutes les campagnes',
createActions: [],
globalItems,
globalBackLabel: 'Toutes les campagnes',
globalBackRoute: '/campaigns'
});
});
}
/**
* Layout en niveaux par BFS depuis la scène d'entrée (order le plus bas).
* Scènes non atteignables rassemblées dans un niveau "orphelin" tout en bas.
*/
private buildGraph(): void {
if (this.scenes.length === 0) {
this.nodes = []; this.edges = [];
this.svgWidth = 600; this.svgHeight = 200;
return;
}
const sorted = [...this.scenes].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const entry = sorted[0];
const levelOf = new Map<string, number>();
levelOf.set(entry.id!, 0);
const queue: string[] = [entry.id!];
while (queue.length > 0) {
const curId = queue.shift()!;
const curLevel = levelOf.get(curId)!;
const curScene = this.scenes.find(s => s.id === curId);
if (!curScene?.branches) continue;
for (const b of curScene.branches) {
if (!levelOf.has(b.targetSceneId)) {
levelOf.set(b.targetSceneId, curLevel + 1);
queue.push(b.targetSceneId);
}
}
}
const reachableMax = levelOf.size > 0 ? Math.max(...Array.from(levelOf.values())) : 0;
const orphanLevel = reachableMax + 1;
for (const s of this.scenes) {
if (!levelOf.has(s.id!)) levelOf.set(s.id!, orphanLevel);
}
const byLevel = new Map<number, Scene[]>();
for (const s of this.scenes) {
const lvl = levelOf.get(s.id!)!;
if (!byLevel.has(lvl)) byLevel.set(lvl, []);
byLevel.get(lvl)!.push(s);
}
const maxPerLevel = Math.max(...Array.from(byLevel.values()).map(arr => arr.length));
const rowWidth = maxPerLevel * this.NODE_WIDTH + (maxPerLevel - 1) * this.H_SPACING;
const nodes: GraphNode[] = [];
for (const [lvl, arr] of byLevel.entries()) {
const count = arr.length;
const levelWidth = count * this.NODE_WIDTH + (count - 1) * this.H_SPACING;
const startX = (rowWidth - levelWidth) / 2;
arr.forEach((s, i) => {
nodes.push({
id: s.id!,
name: s.name,
displayName: this.truncate(s.name),
x: startX + i * (this.NODE_WIDTH + this.H_SPACING),
y: lvl * (this.NODE_HEIGHT + this.V_SPACING)
});
});
}
this.nodes = nodes;
this.recomputeEdges();
this.svgWidth = Math.max(rowWidth + 40, 600);
this.svgHeight = (orphanLevel + 1) * (this.NODE_HEIGHT + this.V_SPACING) + 40;
}
/**
* Recalcule la geometrie des aretes a partir des positions courantes des noeuds.
* Appele apres le layout initial et apres chaque deplacement manuel d'un noeud.
*/
private recomputeEdges(): void {
const nodeMap = new Map(this.nodes.map(n => [n.id, n]));
const edges: GraphEdge[] = [];
for (const scene of this.scenes) {
const from = nodeMap.get(scene.id!);
if (!from || !scene.branches) continue;
// On positionne chaque label a une fraction t differente de l'arete selon
// son index parmi les sorties du meme noeud source. Evite le chevauchement
// des labels au milieu quand plusieurs aretes convergent/divergent.
const siblings = scene.branches.filter(b => nodeMap.has(b.targetSceneId));
const count = siblings.length;
siblings.forEach((b, idx) => {
const to = nodeMap.get(b.targetSceneId)!;
const x1 = from.x + this.NODE_WIDTH / 2;
const y1 = from.y + this.NODE_HEIGHT;
const x2 = to.x + this.NODE_WIDTH / 2;
const y2 = to.y;
// t ∈ [0.25, 0.55] : labels plutot pres de la source, echelonnes.
const t = count === 1 ? 0.5 : 0.25 + (idx / (count - 1)) * 0.3;
const key = `${scene.id}|${b.targetSceneId}|${idx}`;
const offset = this.labelOffsets.get(key) ?? { dx: 0, dy: 0 };
edges.push({
key,
label: b.label,
x1, y1, x2, y2,
labelX: x1 + (x2 - x1) * t + offset.dx,
labelY: y1 + (y2 - y1) * t - 4 + offset.dy
});
});
}
this.edges = edges;
}
/**
* Convertit des coordonnees ecran (PointerEvent) en coordonnees SVG via la CTM
* inverse. Necessaire car le SVG peut etre redimensionne par max-width.
*/
private toSvgCoords(evt: PointerEvent): { x: number; y: number } {
const svg = this.svgEl?.nativeElement;
if (!svg) return { x: evt.clientX, y: evt.clientY };
const pt = svg.createSVGPoint();
pt.x = evt.clientX;
pt.y = evt.clientY;
const ctm = svg.getScreenCTM();
if (!ctm) return { x: evt.clientX, y: evt.clientY };
const local = pt.matrixTransform(ctm.inverse());
return { x: local.x, y: local.y };
}
onPointerDown(evt: PointerEvent, node: GraphNode): void {
// Bouton gauche uniquement.
if (evt.button !== 0) return;
evt.preventDefault();
const { x, y } = this.toSvgCoords(evt);
this.draggingId = node.id;
this.dragOffsetX = x - node.x;
this.dragOffsetY = y - node.y;
this.dragMoved = false;
(evt.target as Element).setPointerCapture?.(evt.pointerId);
}
onPointerMove(evt: PointerEvent): void {
const { x, y } = this.toSvgCoords(evt);
if (this.draggingLabelKey) {
const edge = this.edges.find(e => e.key === this.draggingLabelKey);
if (!edge) return;
const newX = x - this.dragOffsetX;
const newY = y - this.dragOffsetY;
if (!this.dragMoved && Math.hypot(newX - edge.labelX, newY - edge.labelY) < this.DRAG_THRESHOLD) return;
this.dragMoved = true;
// Recalcule la position automatique courante puis stocke la difference,
// pour que l'offset reste valable meme apres deplacement d'un noeud.
const auto = this.autoLabelPosition(edge.key);
if (auto) {
this.labelOffsets.set(edge.key, { dx: newX - auto.x, dy: newY - auto.y });
}
edge.labelX = newX;
edge.labelY = newY;
return;
}
if (!this.draggingId) return;
const node = this.nodes.find(n => n.id === this.draggingId);
if (!node) return;
// Empeche le noeud de partir en coordonnees negatives : sinon il sort
// du viewport SVG et se fait clipper par le navigateur (le SVG a
// overflow: hidden par defaut quand on lui donne width/height explicites).
const newX = Math.max(0, x - this.dragOffsetX);
const newY = Math.max(0, y - this.dragOffsetY);
if (!this.dragMoved) {
const dx = newX - node.x;
const dy = newY - node.y;
if (Math.hypot(dx, dy) >= this.DRAG_THRESHOLD) this.dragMoved = true;
else return;
}
node.x = newX;
node.y = newY;
this.recomputeEdges();
this.fitSvgToNodes();
}
/**
* Recalcule la position "auto" (sans offset manuel) du label d'une arete
* a partir de sa cle. Utilise pour deriver le delta a stocker pendant le drag.
*/
private autoLabelPosition(key: string): { x: number; y: number } | null {
const [sourceId, targetId, idxStr] = key.split('|');
const idx = Number(idxStr);
const scene = this.scenes.find(s => s.id === sourceId);
if (!scene?.branches) return null;
const siblings = scene.branches.filter(b => this.nodes.some(n => n.id === b.targetSceneId));
const count = siblings.length;
if (idx >= count) return null;
const from = this.nodes.find(n => n.id === sourceId);
const to = this.nodes.find(n => n.id === targetId);
if (!from || !to) return null;
const x1 = from.x + this.NODE_WIDTH / 2;
const y1 = from.y + this.NODE_HEIGHT;
const x2 = to.x + this.NODE_WIDTH / 2;
const y2 = to.y;
const t = count === 1 ? 0.5 : 0.25 + (idx / (count - 1)) * 0.3;
return { x: x1 + (x2 - x1) * t, y: y1 + (y2 - y1) * t - 4 };
}
onLabelPointerDown(evt: PointerEvent, edge: GraphEdge): void {
if (evt.button !== 0) return;
// Empeche l'event de remonter au <g class="node"> ou au svg, sinon on
// declencherait aussi un drag de noeud.
evt.stopPropagation();
evt.preventDefault();
const { x, y } = this.toSvgCoords(evt);
this.draggingLabelKey = edge.key;
this.dragOffsetX = x - edge.labelX;
this.dragOffsetY = y - edge.labelY;
this.dragMoved = false;
(evt.target as Element).setPointerCapture?.(evt.pointerId);
}
/**
* Agrandit le SVG si un noeud s'approche du bord droit ou bas, pour eviter
* que le contenu deplace soit rogne. On ne reduit jamais en-dessous de la
* taille initiale du layout pour rester stable visuellement.
*/
private fitSvgToNodes(): void {
const margin = 40;
let maxX = 600;
let maxY = 200;
for (const n of this.nodes) {
if (n.x + this.NODE_WIDTH + margin > maxX) maxX = n.x + this.NODE_WIDTH + margin;
if (n.y + this.NODE_HEIGHT + margin > maxY) maxY = n.y + this.NODE_HEIGHT + margin;
}
if (maxX > this.svgWidth) this.svgWidth = maxX;
if (maxY > this.svgHeight) this.svgHeight = maxY;
}
onPointerUp(evt: PointerEvent): void {
if (this.draggingLabelKey) {
this.draggingLabelKey = null;
this.dragMoved = false;
(evt.target as Element).releasePointerCapture?.(evt.pointerId);
return;
}
if (!this.draggingId) return;
const id = this.draggingId;
const moved = this.dragMoved;
this.draggingId = null;
this.dragMoved = false;
(evt.target as Element).releasePointerCapture?.(evt.pointerId);
// Si le pointeur n'a pas reellement bouge, on traite comme un clic d'ouverture.
if (!moved) this.openScene(id);
}
private truncate(text: string): string {
return text.length > this.MAX_LABEL_CHARS
? text.slice(0, this.MAX_LABEL_CHARS - 1) + '…'
: text;
}
openScene(sceneId: string): void {
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', sceneId]);
}
back(): void {
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]);
}
ngOnDestroy(): void {
this.layoutService.hide();
}
}

View File

@@ -0,0 +1,78 @@
<div class="view-page" *ngIf="chapter">
<header class="view-header">
<div>
<h1>
<lucide-icon *ngIf="chapter.icon" [img]="resolveCampaignIcon(chapter.icon)" [size]="22" class="title-icon"></lucide-icon>
{{ chapter.name }}
</h1>
<p class="view-subtitle">Chapitre</p>
</div>
<div class="view-actions">
<button type="button" class="btn-secondary" (click)="openGraph()"
title="Voir l'organigramme des scènes et de leurs branches">
<lucide-icon [img]="Network" [size]="14"></lucide-icon>
Carte du chapitre
</button>
<button type="button" class="btn-primary" (click)="editMode()">
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
Modifier
</button>
<button type="button" class="btn-danger" (click)="deleteChapter()" title="Supprimer le chapitre et ses scènes">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</header>
<!-- 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>
</section>
<!-- Cartes & plans -->
<section class="view-section" *ngIf="(chapter.mapImageIds?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2>
<app-image-gallery [imageIds]="chapter.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
</section>
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">📖</span> Synopsis</h2>
<p class="view-section-body" *ngIf="chapter.description?.trim(); else emptyDesc">{{ chapter.description }}</p>
<ng-template #emptyDesc><p class="view-section-empty">Non renseigné</p></ng-template>
</section>
<div class="view-row">
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🎯</span> Objectifs des joueurs</h2>
<p class="view-section-body" *ngIf="chapter.playerObjectives?.trim(); else emptyObj">{{ chapter.playerObjectives }}</p>
<ng-template #emptyObj><p class="view-section-empty">Non renseigné</p></ng-template>
</section>
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon"></span> Enjeux narratifs</h2>
<p class="view-section-body" *ngIf="chapter.narrativeStakes?.trim(); else emptyNs">{{ chapter.narrativeStakes }}</p>
<ng-template #emptyNs><p class="view-section-empty">Non renseigné</p></ng-template>
</section>
</div>
<section class="view-section view-section--private" *ngIf="chapter.gmNotes?.trim()">
<h2 class="view-section-title">
<span class="view-section-icon">🔒</span>
Notes du Maître de Jeu
</h2>
<p class="view-section-body">{{ chapter.gmNotes }}</p>
</section>
<section class="view-section" *ngIf="loreId && (chapter.relatedPageIds?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🔗</span> Pages Lore associées</h2>
<div class="view-chips">
<a class="view-chip"
*ngFor="let relId of chapter.relatedPageIds"
[routerLink]="['/lore', loreId, 'pages', relId]">
{{ titleOfRelated(relId) }}
</a>
</div>
</section>
</div>

View File

@@ -0,0 +1 @@
// Styles partagés via styles/_view.scss

View File

@@ -0,0 +1,152 @@
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 { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Pencil, Network, Trash2 } from 'lucide-angular';
import { resolveCampaignIcon } from '../../campaign-icons';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { PageService } from '../../../services/page.service';
import { LayoutService, GlobalItem } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Campaign, Chapter } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model';
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
/**
* Écran de consultation d'un Chapitre (lecture seule).
* Route : /campaigns/:campaignId/arcs/:arcId/chapters/:chapterId
*/
@Component({
selector: 'app-chapter-view',
standalone: true,
imports: [CommonModule, RouterModule, LucideAngularModule, ImageGalleryComponent],
templateUrl: './chapter-view.component.html',
styleUrls: ['./chapter-view.component.scss']
})
export class ChapterViewComponent implements OnInit, OnDestroy {
readonly Pencil = Pencil;
readonly Network = Network;
readonly Trash2 = Trash2;
readonly resolveCampaignIcon = resolveCampaignIcon;
campaignId = '';
arcId = '';
chapterId = '';
chapter: Chapter | null = null;
loreId: string | null = null;
availablePages: Page[] = [];
constructor(
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
) {}
ngOnInit(): void {
this.route.paramMap.subscribe(pm => {
const newCampaignId = pm.get('campaignId')!;
const newArcId = pm.get('arcId')!;
const newChapterId = pm.get('chapterId')!;
if (newChapterId !== this.chapterId ||
newArcId !== this.arcId ||
newCampaignId !== this.campaignId) {
this.campaignId = newCampaignId;
this.arcId = newArcId;
this.chapterId = newChapterId;
this.load();
}
});
}
private load(): void {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
chapter: this.campaignService.getChapterById(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
}).pipe(
switchMap(data => {
const lid = data.campaign.loreId ?? null;
const pages$ = lid ? this.pageService.getByLoreId(lid) : of([] as Page[]);
return pages$.pipe(switchMap(pages => of({ ...data, pages, loreId: lid })));
})
).subscribe(({ campaign, allCampaigns, chapter, treeData, pages, loreId }) => {
this.chapter = chapter;
this.loreId = loreId;
this.availablePages = pages;
this.pageTitleService.set(chapter.name);
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
}));
this.layoutService.show({
title: campaign.name,
items: buildCampaignTree(this.campaignId, treeData),
footerLabel: 'Toutes les campagnes',
createActions: [
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
],
globalItems,
globalBackLabel: 'Toutes les campagnes',
globalBackRoute: '/campaigns'
});
});
}
titleOfRelated(pageId: string): string {
return this.availablePages.find(p => p.id === pageId)?.title ?? '(page supprimée)';
}
editMode(): void {
this.router.navigate([
'/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'edit'
]);
}
openGraph(): void {
this.router.navigate([
'/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'graph'
]);
}
/**
* Suppression en cascade : récupère le compte de scènes qui tomberont avec
* le chapitre, l'annonce dans la confirmation, puis délègue au backend.
*/
deleteChapter(): void {
if (!this.chapter) return;
const chapter = this.chapter;
this.campaignService.getChapterDeletionImpact(chapter.id!).subscribe({
next: impact => {
const lines = [`Supprimer le chapitre "${chapter.name}" ?`];
if (impact.scenes > 0) {
lines.push('');
lines.push(`Cette action supprimera aussi : ${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}.`);
}
lines.push('');
lines.push('Cette action est irréversible.');
if (!confirm(lines.join('\n'))) return;
this.campaignService.deleteChapter(chapter.id!).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]),
error: () => console.error('Erreur lors de la suppression du chapitre')
});
},
error: () => console.error('Impossible de récupérer les dépendances du chapitre')
});
}
ngOnDestroy(): void {
this.layoutService.hide();
}
}