Changement sur le Readme
Ajout d'une partie spécifique pour des PNJ dans la partie campagne
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
<div class="campaign-detail" *ngIf="campaign">
|
||||
|
||||
<!-- ============ Header : mode lecture ============ -->
|
||||
<div class="detail-header" *ngIf="!editing">
|
||||
<div class="header-texts">
|
||||
<h1>{{ campaign.name }}</h1>
|
||||
<p class="description">{{ campaign.description }}</p>
|
||||
<div class="meta">
|
||||
<span class="badge">{{ campaign.playerCount || 0 }} joueurs</span>
|
||||
|
||||
<!-- Badge "Univers" : lien vers le Lore associé si présent -->
|
||||
<a *ngIf="linkedLore"
|
||||
class="badge badge-lore"
|
||||
[routerLink]="['/lore', linkedLore.id]"
|
||||
title="Ouvrir l'univers associé">
|
||||
<lucide-icon [img]="Globe" [size]="12"></lucide-icon>
|
||||
{{ linkedLore.name }}
|
||||
</a>
|
||||
|
||||
<!-- Campagne liée à un Lore qui n'existe plus (supprimé ailleurs) -->
|
||||
<span *ngIf="campaign.loreId && !linkedLore" class="badge badge-lore-missing" title="L'univers associé est introuvable">
|
||||
<lucide-icon [img]="Globe" [size]="12"></lucide-icon>
|
||||
Univers introuvable
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-secondary" (click)="startEdit()">
|
||||
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
||||
Modifier
|
||||
</button>
|
||||
<button type="button" class="btn-danger" (click)="deleteCampaign()">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ Header : mode édition inline ============ -->
|
||||
<div class="detail-header edit-mode" *ngIf="editing">
|
||||
<div class="field">
|
||||
<label>Nom</label>
|
||||
<input type="text" [(ngModel)]="editName" name="editName" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Description</label>
|
||||
<textarea [(ngModel)]="editDescription" name="editDescription" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Univers associé</label>
|
||||
<select [(ngModel)]="editLoreId" name="editLoreId">
|
||||
<option value="">— Aucun univers (campagne libre) —</option>
|
||||
<option *ngFor="let lore of availableLores" [value]="lore.id">{{ lore.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Système de JDR</label>
|
||||
<select [(ngModel)]="editGameSystemId" name="editGameSystemId">
|
||||
<option value="">— Aucun (générique) —</option>
|
||||
<option *ngFor="let gs of availableGameSystems" [value]="gs.id">{{ gs.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-primary" (click)="saveEdit()" [disabled]="!editName.trim()">
|
||||
Sauvegarder
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancelEdit()">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="detail-section personas-section" *ngIf="!editing">
|
||||
<div class="section-header">
|
||||
<h2>Personnages</h2>
|
||||
</div>
|
||||
|
||||
<!-- Sous-section : Personnages joueurs (PJ) -->
|
||||
<div class="persona-subsection">
|
||||
<div class="subsection-header">
|
||||
<h3>
|
||||
<lucide-icon [img]="User" [size]="16"></lucide-icon>
|
||||
Personnages joueurs
|
||||
<span class="count-badge" *ngIf="characters.length > 0">{{ characters.length }}</span>
|
||||
</h3>
|
||||
<button class="btn-add" (click)="createCharacter()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Nouveau PJ
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="characters-grid" *ngIf="characters.length > 0">
|
||||
<div class="character-card" *ngFor="let character of characters" (click)="editCharacter(character)">
|
||||
<lucide-icon [img]="User" [size]="20" class="character-icon"></lucide-icon>
|
||||
<div class="character-info">
|
||||
<span class="character-name">{{ character.name }}</span>
|
||||
<span class="character-snippet">{{ personaSnippet(character) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-state empty-state--compact" *ngIf="characters.length === 0">
|
||||
<p>Aucun personnage joueur pour le moment.</p>
|
||||
<button class="btn-add-first" (click)="createCharacter()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Créer votre premier PJ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sous-section : Personnages non-joueurs (PNJ) -->
|
||||
<div class="persona-subsection">
|
||||
<div class="subsection-header">
|
||||
<h3>
|
||||
<lucide-icon [img]="Drama" [size]="16"></lucide-icon>
|
||||
Personnages non-joueurs
|
||||
<span class="count-badge" *ngIf="npcs.length > 0">{{ npcs.length }}</span>
|
||||
</h3>
|
||||
<button class="btn-add" (click)="createNpc()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Nouveau PNJ
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="characters-grid" *ngIf="npcs.length > 0">
|
||||
<div class="character-card" *ngFor="let npc of npcs" (click)="editNpc(npc)">
|
||||
<lucide-icon [img]="Drama" [size]="20" class="character-icon character-icon--npc"></lucide-icon>
|
||||
<div class="character-info">
|
||||
<span class="character-name">{{ npc.name }}</span>
|
||||
<span class="character-snippet">{{ personaSnippet(npc) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-state empty-state--compact" *ngIf="npcs.length === 0">
|
||||
<p>Aucun PNJ pour le moment.</p>
|
||||
<button class="btn-add-first" (click)="createNpc()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Créer votre premier PNJ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="detail-section arcs-section" *ngIf="!editing">
|
||||
<div class="section-header">
|
||||
<h2>Arcs narratifs</h2>
|
||||
<button class="btn-add" (click)="createArc()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Nouvel arc
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="arcs-grid" *ngIf="arcs.length > 0">
|
||||
<div class="arc-card" *ngFor="let arc of arcs" (click)="openArc(arc)">
|
||||
<lucide-icon [img]="Swords" [size]="24" class="arc-icon"></lucide-icon>
|
||||
<span class="arc-name">{{ arc.name }}</span>
|
||||
<span class="arc-meta">{{ chapterCountByArc[arc.id!] || 0 }} chapitres</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" *ngIf="arcs.length === 0">
|
||||
<lucide-icon [img]="Swords" [size]="40" class="empty-icon"></lucide-icon>
|
||||
<p>Aucun arc narratif pour le moment.</p>
|
||||
<button class="btn-add-first" (click)="createArc()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Créer votre premier arc
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,326 @@
|
||||
.campaign-detail {
|
||||
padding: 2.5rem 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chaque bloc (resume, PJ, arcs) est encapsule dans une carte distincte
|
||||
* pour separer visuellement les zones. Le gap au niveau du parent gere
|
||||
* les espacements — les sections ne portent plus de margin-bottom.
|
||||
*/
|
||||
.detail-section {
|
||||
background: #0d1117;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem 1.75rem;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
|
||||
h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #6b7280;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
background: #1e3a5f;
|
||||
color: #60a5fa;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
// Lien cliquable vers le Lore associé (weak cross-context link).
|
||||
.badge-lore {
|
||||
background: #2d2450;
|
||||
color: #a78bfa;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: #3d3168;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
// Cas dégradé : loreId renseigné mais Lore introuvable (supprimé).
|
||||
.badge-lore-missing {
|
||||
background: #3a1e1e;
|
||||
color: #f87171;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
// Sticky : Modifier/Supprimer restent accessibles pendant le scroll de la
|
||||
// campagne (potentiellement très longue avec arcs / chapitres / scènes).
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: #0a0a14;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid #1f2937;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
|
||||
.header-texts { flex: 1; min-width: 0; }
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Variante mode édition : champs empilés verticalement.
|
||||
&.edit-mode {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
label { color: #9ca3af; font-size: 0.8rem; font-weight: 500; }
|
||||
input, textarea, select {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
padding: 0.6rem 0.85rem;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
|
||||
&:focus { outline: none; border-color: #6c63ff; }
|
||||
}
|
||||
textarea { resize: vertical; }
|
||||
}
|
||||
|
||||
.header-actions { justify-content: flex-end; }
|
||||
}
|
||||
}
|
||||
|
||||
// Boutons partagés.
|
||||
.btn-primary, .btn-secondary, .btn-danger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.btn-primary { background: #6c63ff; color: white; &:hover:not(:disabled) { background: #5b52e0; } }
|
||||
.btn-secondary { background: #1f2937; color: #d1d5db; &:hover:not(:disabled) { background: #374151; } }
|
||||
.btn-danger { background: #3a1e1e; color: #f87171; &:hover:not(:disabled) { background: #5a2e2e; } }
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
h2 { color: #d1d5db; font-size: 1rem; font-weight: 600; }
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #6c63ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover { background: #5b52e0; }
|
||||
}
|
||||
|
||||
.arcs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.arc-card {
|
||||
background: #111827;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, transform 0.2s;
|
||||
|
||||
&:hover { border-color: #6c63ff; transform: translateY(-2px); }
|
||||
|
||||
.arc-icon { color: #6c63ff; }
|
||||
.arc-name { color: white; font-size: 0.9rem; font-weight: 600; }
|
||||
.arc-meta { color: #6b7280; font-size: 0.75rem; }
|
||||
}
|
||||
|
||||
|
||||
// Encart "Personnages" qui regroupe les sous-sections PJ et PNJ.
|
||||
.personas-section {
|
||||
|
||||
.persona-subsection + .persona-subsection {
|
||||
margin-top: 1.75rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #1f2937;
|
||||
}
|
||||
}
|
||||
|
||||
.subsection-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #d1d5db;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin: 0;
|
||||
|
||||
lucide-icon { color: #a78bfa; }
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
padding: 0 0.45rem;
|
||||
background: #1f2937;
|
||||
color: #a78bfa;
|
||||
border-radius: 999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.characters-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.character-card {
|
||||
background: #111827;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 10px;
|
||||
padding: 0.9rem 1rem;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, transform 0.2s;
|
||||
|
||||
&:hover { border-color: #a78bfa; transform: translateY(-2px); }
|
||||
|
||||
.character-icon { color: #a78bfa; flex-shrink: 0; margin-top: 2px; }
|
||||
.character-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.character-name { color: white; font-size: 0.95rem; font-weight: 600; }
|
||||
.character-snippet {
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 4rem 2rem;
|
||||
color: #6b7280;
|
||||
|
||||
.empty-icon { color: #374151; }
|
||||
p { font-size: 0.95rem; }
|
||||
|
||||
// Variante condensée pour les sous-sections PJ/PNJ — pas besoin du
|
||||
// padding vertical massif quand l'encart parent en porte déjà.
|
||||
&.empty-state--compact {
|
||||
padding: 1.5rem 1rem;
|
||||
gap: 0.75rem;
|
||||
|
||||
p {
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Variante d'icône pour les cartes PNJ (rouge-violet pour différencier des PJ).
|
||||
.character-icon--npc { color: #c084fc !important; }
|
||||
|
||||
.btn-add-first {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.6rem 1.25rem;
|
||||
background: #6c63ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover { background: #5b52e0; }
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, User, Dices, Drama } from 'lucide-angular';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError, switchMap, filter, map } from 'rxjs/operators';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { LoreService } from '../../../services/lore.service';
|
||||
import { GameSystemService } from '../../../services/game-system.service';
|
||||
import { GameSystem } from '../../../services/game-system.model';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { Character } from '../../../services/character.model';
|
||||
import { Npc } from '../../../services/npc.model';
|
||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
import { Campaign, Arc } from '../../../services/campaign.model';
|
||||
import { Lore } from '../../../services/lore.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree, CampaignTreeData } from '../../campaign-tree.helper';
|
||||
|
||||
@Component({
|
||||
selector: 'app-campaign-detail',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule, RouterLink],
|
||||
templateUrl: './campaign-detail.component.html',
|
||||
styleUrls: ['./campaign-detail.component.scss']
|
||||
})
|
||||
export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
readonly Swords = Swords;
|
||||
readonly Plus = Plus;
|
||||
readonly Globe = Globe;
|
||||
readonly Pencil = Pencil;
|
||||
readonly Trash2 = Trash2;
|
||||
readonly User = User;
|
||||
readonly Dices = Dices;
|
||||
readonly Drama = Drama;
|
||||
|
||||
campaign: Campaign | null = null;
|
||||
arcs: Arc[] = [];
|
||||
/** Nombre de chapitres par arc — alimente le compteur des cartes. */
|
||||
chapterCountByArc: Record<string, number> = {};
|
||||
/** Lore associé si `campaign.loreId` est renseigné ; sinon null. */
|
||||
linkedLore: Lore | null = null;
|
||||
/** Lores disponibles pour changer l'association en mode édition. */
|
||||
availableLores: Lore[] = [];
|
||||
/** GameSystems disponibles pour changer l'association en mode édition. */
|
||||
availableGameSystems: GameSystem[] = [];
|
||||
/** GameSystem associé si `campaign.gameSystemId` est renseigné ; sinon null. */
|
||||
linkedGameSystem: GameSystem | null = null;
|
||||
/** Fiches de personnages (PJ) de la campagne. */
|
||||
characters: Character[] = [];
|
||||
/** Fiches de personnages non-joueurs (PNJ) de la campagne. */
|
||||
npcs: Npc[] = [];
|
||||
|
||||
/** Mode édition inline. */
|
||||
editing = false;
|
||||
editName = '';
|
||||
editDescription = '';
|
||||
editLoreId = '';
|
||||
editGameSystemId = '';
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private loreService: LoreService,
|
||||
private gameSystemService: GameSystemService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
// switchMap annule automatiquement le load précédent si l'utilisateur
|
||||
// change de campagne avant que le forkJoin ne réponde — évite qu'une
|
||||
// réponse en retard écrase des données plus récentes (race condition).
|
||||
this.route.paramMap.pipe(
|
||||
map(pm => pm.get('id')),
|
||||
filter((id): id is string => !!id && id !== this.campaign?.id),
|
||||
switchMap(id => forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(id),
|
||||
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 }) => {
|
||||
this.campaign = campaign;
|
||||
this.editing = false;
|
||||
this.loadLinkedLore(campaign);
|
||||
this.loadLinkedGameSystem(campaign);
|
||||
this.loadCharacters(campaign.id!);
|
||||
this.loadNpcs(campaign.id!);
|
||||
this.arcs = treeData.arcs;
|
||||
this.chapterCountByArc = this.computeChapterCounts(treeData);
|
||||
this.showLayout(allCampaigns, treeData);
|
||||
this.pageTitleService.set(campaign.name);
|
||||
});
|
||||
}
|
||||
|
||||
private computeChapterCounts(data: CampaignTreeData): Record<string, number> {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const arcId of Object.keys(data.chaptersByArc)) {
|
||||
counts[arcId] = data.chaptersByArc[arcId].length;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recharge explicitement après une mise à jour locale (ex: saveEdit).
|
||||
* Contrairement au flux ngOnInit, on bypass le filter sur l'ID puisqu'on
|
||||
* veut rafraîchir même si l'ID n'a pas changé.
|
||||
*/
|
||||
private reload(id: string): void {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(id),
|
||||
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 }) => {
|
||||
this.campaign = campaign;
|
||||
this.editing = false;
|
||||
this.loadLinkedLore(campaign);
|
||||
this.loadLinkedGameSystem(campaign);
|
||||
this.loadCharacters(campaign.id!);
|
||||
this.loadNpcs(campaign.id!);
|
||||
this.arcs = treeData.arcs;
|
||||
this.chapterCountByArc = this.computeChapterCounts(treeData);
|
||||
this.showLayout(allCampaigns, treeData);
|
||||
this.pageTitleService.set(campaign.name);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge le Lore associé (si loreId présent). On swallow l'erreur :
|
||||
* si le Lore a été supprimé entre-temps, on affiche simplement "Univers introuvable".
|
||||
*/
|
||||
private loadLinkedLore(campaign: Campaign): void {
|
||||
if (!campaign.loreId) {
|
||||
this.linkedLore = null;
|
||||
return;
|
||||
}
|
||||
this.loreService.getLoreById(campaign.loreId).pipe(
|
||||
catchError(() => of(null))
|
||||
).subscribe(lore => this.linkedLore = lore);
|
||||
}
|
||||
|
||||
/** Même logique pour le GameSystem associé : dégradation si supprimé. */
|
||||
private loadLinkedGameSystem(campaign: Campaign): void {
|
||||
if (!campaign.gameSystemId) {
|
||||
this.linkedGameSystem = null;
|
||||
return;
|
||||
}
|
||||
this.gameSystemService.getById(campaign.gameSystemId).pipe(
|
||||
catchError(() => of(null))
|
||||
).subscribe(gs => this.linkedGameSystem = gs);
|
||||
}
|
||||
|
||||
/** Charge les fiches de personnages (PJ) de la campagne. */
|
||||
private loadCharacters(campaignId: string): void {
|
||||
this.characterService.getByCampaign(campaignId).pipe(
|
||||
catchError(() => of([] as Character[]))
|
||||
).subscribe(list => this.characters = list);
|
||||
}
|
||||
|
||||
/** Symétrique pour les PNJ. */
|
||||
private loadNpcs(campaignId: string): void {
|
||||
this.npcService.getByCampaign(campaignId).pipe(
|
||||
catchError(() => of([] as Npc[]))
|
||||
).subscribe(list => this.npcs = list);
|
||||
}
|
||||
|
||||
createCharacter(): void {
|
||||
if (!this.campaign) return;
|
||||
this.router.navigate(['/campaigns', this.campaign.id, 'characters', 'create']);
|
||||
}
|
||||
|
||||
createNpc(): void {
|
||||
if (!this.campaign) return;
|
||||
this.router.navigate(['/campaigns', this.campaign.id, 'npcs', 'create']);
|
||||
}
|
||||
|
||||
editNpc(npc: Npc): void {
|
||||
if (!this.campaign || !npc.id) return;
|
||||
this.router.navigate(['/campaigns', this.campaign.id, 'npcs', npc.id, 'edit']);
|
||||
}
|
||||
|
||||
editCharacter(character: Character): void {
|
||||
if (!this.campaign || !character.id) return;
|
||||
this.router.navigate(['/campaigns', this.campaign.id, 'characters', character.id, 'edit']);
|
||||
}
|
||||
|
||||
createArc(): void {
|
||||
if (!this.campaign) return;
|
||||
this.router.navigate(['/campaigns', this.campaign.id, 'arcs', 'create']);
|
||||
}
|
||||
|
||||
openArc(arc: Arc): void {
|
||||
if (!this.campaign || !arc.id) return;
|
||||
this.router.navigate(['/campaigns', this.campaign.id, 'arcs', arc.id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait une ligne de résumé depuis le markdown (1re ligne non-vide, non-titre).
|
||||
* Générique : utilisé pour les fiches PJ comme PNJ (mêmes besoins d'aperçu carte).
|
||||
*/
|
||||
personaSnippet(p: { markdownContent?: string | null }): string {
|
||||
if (!p.markdownContent) return '(Fiche vide)';
|
||||
const firstMeaningful = p.markdownContent
|
||||
.split('\n')
|
||||
.map(l => l.trim())
|
||||
.find(l => l && !l.startsWith('#'));
|
||||
if (!firstMeaningful) return '(Fiche vide)';
|
||||
return firstMeaningful.length > 80
|
||||
? firstMeaningful.substring(0, 77) + '…'
|
||||
: firstMeaningful;
|
||||
}
|
||||
|
||||
/** Alias gardé pour compatibilité avec les anciens templates. */
|
||||
characterSnippet(c: Character): string {
|
||||
return this.personaSnippet(c);
|
||||
}
|
||||
|
||||
private showLayout(allCampaigns: Campaign[], data: CampaignTreeData): void {
|
||||
const campaignId = this.campaign!.id!;
|
||||
const globalItems: GlobalItem[] = allCampaigns.map(c => ({
|
||||
id: c.id!,
|
||||
name: c.name,
|
||||
route: `/campaigns/${c.id}`
|
||||
}));
|
||||
|
||||
this.layoutService.show({
|
||||
title: this.campaign!.name,
|
||||
items: buildCampaignTree(campaignId, data),
|
||||
footerLabel: 'Toutes les campagnes',
|
||||
createActions: [
|
||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${campaignId}/arcs/create` }
|
||||
],
|
||||
globalItems,
|
||||
globalBackLabel: 'Toutes les campagnes',
|
||||
globalBackRoute: '/campaigns'
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────── Édition / suppression de la Campagne ───────────────
|
||||
|
||||
startEdit(): void {
|
||||
if (!this.campaign) return;
|
||||
this.editName = this.campaign.name;
|
||||
this.editDescription = this.campaign.description ?? '';
|
||||
this.editLoreId = this.campaign.loreId ?? '';
|
||||
this.editGameSystemId = this.campaign.gameSystemId ?? '';
|
||||
// On charge les Lores et GameSystems disponibles uniquement à l'entrée en mode édition.
|
||||
this.loreService.getAllLores().subscribe({
|
||||
next: (lores) => this.availableLores = lores,
|
||||
error: () => this.availableLores = []
|
||||
});
|
||||
this.gameSystemService.getAll().subscribe({
|
||||
next: (gs) => this.availableGameSystems = gs,
|
||||
error: () => this.availableGameSystems = []
|
||||
});
|
||||
this.editing = true;
|
||||
}
|
||||
|
||||
cancelEdit(): void {
|
||||
this.editing = false;
|
||||
}
|
||||
|
||||
saveEdit(): void {
|
||||
if (!this.campaign || !this.editName.trim()) return;
|
||||
this.campaignService.updateCampaign(this.campaign.id!, {
|
||||
name: this.editName.trim(),
|
||||
description: this.editDescription,
|
||||
playerCount: this.campaign.playerCount ?? 0,
|
||||
loreId: this.editLoreId ? this.editLoreId : null,
|
||||
gameSystemId: this.editGameSystemId ? this.editGameSystemId : null
|
||||
}).subscribe({
|
||||
next: (updated) => {
|
||||
this.campaign = updated;
|
||||
this.editing = false;
|
||||
// Recharge pour actualiser le badge "Univers" et le titre sidebar.
|
||||
this.reload(updated.id!);
|
||||
},
|
||||
error: () => console.error('Erreur lors de la mise à jour de la campagne')
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppression en cascade : récupère d'abord le détail de ce qui sera effacé
|
||||
* (arcs / chapitres / scènes / personnages), affiche le récapitulatif dans
|
||||
* la confirmation, puis supprime. Le cascade est orchestré côté backend dans
|
||||
* une seule transaction.
|
||||
*/
|
||||
deleteCampaign(): void {
|
||||
if (!this.campaign) return;
|
||||
const campaign = this.campaign;
|
||||
this.campaignService.getCampaignDeletionImpact(campaign.id!).subscribe({
|
||||
next: impact => {
|
||||
const parts: string[] = [];
|
||||
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' : ''}`);
|
||||
|
||||
const lines = [`Supprimer définitivement la campagne "${campaign.name}" ?`];
|
||||
if (parts.length) {
|
||||
lines.push('');
|
||||
lines.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('Cette action est irréversible.');
|
||||
|
||||
if (!confirm(lines.join('\n'))) return;
|
||||
this.campaignService.deleteCampaign(campaign.id!).subscribe({
|
||||
next: () => this.router.navigate(['/campaigns']),
|
||||
error: () => console.error('Erreur lors de la suppression de la campagne')
|
||||
});
|
||||
},
|
||||
error: () => console.error('Impossible de récupérer les dépendances de la campagne')
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user