Ajout de tests playwright et correction de tests non passant (pour les tests ajoutés : partie game system ).
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

Correction de plusieurs anomalies : problème de switch entre 2 templates (par exemple si on était sur un template 1 et qu'on voulait passer directement au 2, ce dernier ne chargeait pas) ;
correction du soucis d'apparition de la sidebar à gauche qui disparaissait sans explication ; problème de redirection : lorsqu'on terminait de créer un PJ / PNJ ; on arrivait sur l'accueil de la campagne au lieu de voir le résultat de la création.
Problème de redirection également lors du clique sur un PNJ / PJ sur le coté : on arrivait sur l'édition au lieu de la présentation. Correction de la première lettre stylisée : tout est au même style comme ça plus de probleme de lecture.

Nouveautées : stylisation des modales (notamment suppression, warning.....) avec en prime l'ajout d'un warning lors du changement de système pour avertir que les fiches persos ne sont pas conservées.
Ajout d'une option pour créer un game system directement à la création d'une campagne afin de faciliter la mise en place de cette dernière.
Ajout d'un bouton pour créer un nouveau template directement lorsqu'on créer une page : ça permet de créer un template et de revenir sur la page qu'on était en train de créer sans perdre le titre.

Passage en bêta 0.8.4
This commit is contained in:
2026-05-19 13:37:22 +02:00
parent e346bb4b85
commit ae9455c244
70 changed files with 1908 additions and 495 deletions

View File

@@ -7,9 +7,8 @@ import { LucideAngularModule, BookOpen } 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 { LayoutService } from '../../../services/layout.service';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
@@ -62,21 +61,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
}).subscribe(({ campaign, allCampaigns, treeData }) => {
this.existingArcCount = treeData.arcs.length;
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'
});
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
});
}
@@ -89,7 +74,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
order: this.existingArcCount + 1,
icon: this.selectedIcon
}).subscribe({
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', created.id, 'edit']),
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', created.id]),
error: () => console.error('Erreur lors de la création de l\'arc')
});
}
@@ -99,6 +84,9 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
this.layoutService.hide();
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement / la
// disparition de la sidebar lors des navigations internes a la section.
}
}

View File

@@ -9,16 +9,17 @@ 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 { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Campaign, Arc } from '../../../services/campaign.model';
import { Arc } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model';
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
import { 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';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
/**
* Écran de détail/modification d'un Arc.
@@ -78,7 +79,8 @@ export class ArcEditComponent implements OnInit, OnDestroy {
private npcService: NpcService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService
) {
this.form = this.fb.group({
name: ['', Validators.required],
@@ -142,21 +144,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
resolution: arc.resolution ?? ''
});
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'
});
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
});
}
@@ -183,10 +171,18 @@ export class ArcEditComponent implements OnInit, OnDestroy {
}
delete(): void {
if (!confirm(`Supprimer l'arc "${this.arc?.name}" ? Cette action est irréversible.`)) return;
this.campaignService.deleteArc(this.arcId).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId]),
error: () => console.error('Erreur lors de la suppression')
this.confirmDialog.confirm({
title: 'Supprimer l\'arc',
message: `Supprimer l'arc "${this.arc?.name}" ?`,
details: ['Cette action est irréversible.'],
confirmLabel: 'Supprimer',
variant: 'danger'
}).then(ok => {
if (!ok) return;
this.campaignService.deleteArc(this.arcId).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId]),
error: () => console.error('Erreur lors de la suppression')
});
});
}
@@ -195,6 +191,9 @@ export class ArcEditComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
this.layoutService.hide();
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement / la
// disparition de la sidebar lors des navigations internes a la section.
}
}

View File

@@ -9,12 +9,13 @@ 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 { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Campaign, Arc } from '../../../services/campaign.model';
import { Arc } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model';
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
/**
* Écran de consultation d'un Arc narratif (lecture seule).
@@ -50,7 +51,8 @@ export class ArcViewComponent implements OnInit, OnDestroy {
private npcService: NpcService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService
) {}
ngOnInit(): void {
@@ -83,20 +85,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
this.availablePages = pages;
this.pageTitleService.set(arc.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'
});
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
});
}
@@ -122,18 +111,24 @@ export class ArcViewComponent implements OnInit, OnDestroy {
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' : ''}`);
const lines = [`Supprimer l'arc "${arc.name}" ?`];
const details: string[] = [];
if (parts.length) {
lines.push('');
lines.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
details.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
}
lines.push('');
lines.push('Cette action est irréversible.');
details.push('Cette action est irréversible.');
if (!confirm(lines.join('\n'))) return;
this.campaignService.deleteArc(arc.id!).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId]),
error: () => console.error('Erreur lors de la suppression de l\'arc')
this.confirmDialog.confirm({
title: 'Supprimer l\'arc',
message: `Supprimer l'arc "${arc.name}" ?`,
details,
confirmLabel: 'Supprimer',
variant: 'danger'
}).then(ok => {
if (!ok) return;
this.campaignService.deleteArc(arc.id!).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId]),
error: () => console.error('Erreur lors de la suppression de l\'arc')
});
});
},
error: () => console.error('Impossible de récupérer les dépendances de l\'arc')
@@ -141,6 +136,9 @@ export class ArcViewComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
this.layoutService.hide();
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement / la
// disparition de la sidebar lors des navigations internes a la section.
}
}

View File

@@ -3,8 +3,8 @@ import { switchMap, map } from 'rxjs/operators';
import { CampaignService } from '../services/campaign.service';
import { CharacterService } from '../services/character.service';
import { NpcService } from '../services/npc.service';
import { TreeItem } from '../services/layout.service';
import { Arc, Chapter, Scene } from '../services/campaign.model';
import { TreeItem, SecondarySidebarConfig, GlobalItem } from '../services/layout.service';
import { Arc, Chapter, Scene, Campaign } from '../services/campaign.model';
import { Character } from '../services/character.model';
import { Npc } from '../services/npc.model';
@@ -83,7 +83,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
const characterItems: TreeItem[] = sortedCharacters.map(ch => ({
id: `character-${ch.id}`,
label: ch.name,
route: `/campaigns/${campaignId}/characters/${ch.id}/edit`
route: `/campaigns/${campaignId}/characters/${ch.id}`
}));
const charactersNode: TreeItem = {
@@ -107,7 +107,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
const npcItems: TreeItem[] = sortedNpcs.map(n => ({
id: `npc-${n.id}`,
label: n.name,
route: `/campaigns/${campaignId}/npcs/${n.id}/edit`
route: `/campaigns/${campaignId}/npcs/${n.id}`
}));
const npcsNode: TreeItem = {
@@ -172,3 +172,35 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
return [...arcNodes, charactersNode, npcsNode];
}
/**
* Construit la SecondarySidebarConfig complete d'une campagne a partir des
* donnees deja chargees. A utiliser quand le composant fait deja un forkJoin
* pour ses propres donnees (arc-view, scene-edit, etc.) et a deja `campaign`,
* `allCampaigns` et `treeData` en main — evite de refaire les memes HTTP.
*
* Pour les composants qui n'ont pas d'autre fetch a faire (character-view,
* npc-view...), preferer CampaignSidebarService.show(campaignId) qui orchestre
* le forkJoin et appelle layoutService.show() en une seule ligne.
*/
export function buildCampaignSidebarConfig(
campaign: Campaign,
allCampaigns: Campaign[],
treeData: CampaignTreeData,
campaignId: string
): SecondarySidebarConfig {
const globalItems: GlobalItem[] = allCampaigns.map(c => ({
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
}));
return {
title: campaign.name,
items: buildCampaignTree(campaignId, treeData),
footerLabel: 'Toutes les campagnes',
createActions: [
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${campaignId}/arcs/create` }
],
globalItems,
globalBackLabel: 'Toutes les campagnes',
globalBackRoute: '/campaigns'
};
}

View File

@@ -50,14 +50,50 @@
<div class="field">
<label for="campaign-game-system">Système de JDR</label>
<select id="campaign-game-system" formControlName="gameSystemId">
<select *ngIf="!creatingGameSystem" id="campaign-game-system" formControlName="gameSystemId">
<option value="">— Aucun (campagne générique) —</option>
<option *ngFor="let gs of availableGameSystems" [value]="gs.id">{{ gs.name }}</option>
<option [value]="CREATE_GAMESYSTEM_SENTINEL">+ Créer un nouveau système…</option>
</select>
<p class="hint">
<!-- Mode creation inline : remplace temporairement le select. -->
<div *ngIf="creatingGameSystem" class="inline-create">
<input
type="text"
[(ngModel)]="newGameSystemName"
[ngModelOptions]="{ standalone: true }"
placeholder="Nom du nouveau système (ex: D&D 5e, Nimble, Maison)"
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
(keydown.escape)="cancelCreateGameSystem()"
autofocus
/>
<div class="inline-create-actions">
<button type="button" class="btn-inline-primary"
[disabled]="!newGameSystemName.trim() || creatingGameSystemInFlight"
(click)="submitCreateGameSystem()">
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
Créer
</button>
<button type="button" class="btn-inline-secondary" (click)="cancelCreateGameSystem()">
Annuler
</button>
</div>
<p class="hint">
Création rapide — vous pourrez ajouter les règles, les templates de fiches PJ/PNJ
et le reste depuis la section "Systèmes" plus tard.
</p>
</div>
<p *ngIf="!creatingGameSystem" class="hint">
Optionnel. Si défini, l'IA injectera les règles du système (classes, combat, lore...)
dans ses suggestions pour respecter les mécaniques du JDR.
</p>
<p *ngIf="!creatingGameSystem" class="hint hint-warning">
⚠️ Le système de jeu choisi détermine aussi le <strong>template des fiches de PJ et PNJ</strong>.
Le changer plus tard rendra les champs des fiches existantes invisibles
(les données restent stockées mais ne s'afficheront qu'en revenant à l'ancien système).
Choisissez bien dès le départ si possible.
</p>
</div>
<div class="info-box">

View File

@@ -87,6 +87,81 @@ form {
input[type="number"] { width: 120px; }
}
.inline-create {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.875rem;
background: #1a2233;
border: 1px solid #2d3748;
border-left: 3px solid #6c63ff;
border-radius: 8px;
input {
width: 100%;
background: #1f2937;
border: 1px solid #374151;
border-radius: 6px;
padding: 0.6rem 0.875rem;
color: white;
font-size: 0.9rem;
outline: none;
transition: border-color 0.2s;
&::placeholder { color: #4b5563; }
&:focus { border-color: #6c63ff; }
}
}
.inline-create-actions {
display: flex;
gap: 0.5rem;
}
.btn-inline-primary {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
background: #6c63ff;
color: white;
border: none;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
&:hover:not(:disabled) { background: #5b52e0; }
&:disabled { opacity: 0.4; cursor: not-allowed; }
}
.btn-inline-secondary {
padding: 0.5rem 0.875rem;
background: transparent;
color: #9ca3af;
border: 1px solid #374151;
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
transition: background 0.2s, color 0.2s;
&:hover { background: #1f2937; color: white; }
}
.hint-warning {
margin-top: 0.5rem;
background: rgba(234, 179, 8, 0.08);
border-left: 3px solid #eab308;
border-radius: 4px;
padding: 0.625rem 0.875rem;
color: #fbbf24;
font-size: 0.8rem;
line-height: 1.5;
strong { color: #fde68a; }
}
.info-box {
background: #1f2937;
border-radius: 8px;

View File

@@ -1,7 +1,8 @@
import { Component, EventEmitter, OnInit, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { LucideAngularModule, BookCopy, X } from 'lucide-angular';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, BookCopy, X, Plus, Check } from 'lucide-angular';
import { LoreService } from '../../../services/lore.service';
import { Lore } from '../../../services/lore.model';
import { GameSystemService } from '../../../services/game-system.service';
@@ -22,7 +23,7 @@ export interface CampaignCreatePayload {
@Component({
selector: 'app-campaign-create',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule],
imports: [CommonModule, ReactiveFormsModule, FormsModule, LucideAngularModule],
templateUrl: './campaign-create.component.html',
styleUrls: ['./campaign-create.component.scss']
})
@@ -32,6 +33,11 @@ export class CampaignCreateComponent implements OnInit {
readonly BookCopy = BookCopy;
readonly X = X;
readonly Plus = Plus;
readonly Check = Check;
/** Valeur sentinelle de l'option "Creer un systeme" dans le <select>. */
readonly CREATE_GAMESYSTEM_SENTINEL = '__create__';
form: FormGroup;
/** Lores disponibles pour association. Chargés à l'ouverture de la modal. */
@@ -39,6 +45,11 @@ export class CampaignCreateComponent implements OnInit {
/** GameSystems disponibles pour association. */
availableGameSystems: GameSystem[] = [];
/** Mode creation inline d'un GameSystem depuis le dropdown. */
creatingGameSystem = false;
newGameSystemName = '';
creatingGameSystemInFlight = false;
constructor(
private fb: FormBuilder,
private loreService: LoreService,
@@ -62,6 +73,47 @@ export class CampaignCreateComponent implements OnInit {
next: (gs) => this.availableGameSystems = gs,
error: () => this.availableGameSystems = []
});
// Detecte la selection de l'option sentinelle "Creer un systeme" et bascule
// en mode creation inline. On reinitialise immediatement le control a ''
// pour que la sentinelle ne reste pas en valeur reelle du form.
this.form.get('gameSystemId')?.valueChanges.subscribe(value => {
if (value === this.CREATE_GAMESYSTEM_SENTINEL) {
this.form.get('gameSystemId')?.setValue('', { emitEvent: false });
this.startCreateGameSystem();
}
});
}
startCreateGameSystem(): void {
this.creatingGameSystem = true;
this.newGameSystemName = '';
}
cancelCreateGameSystem(): void {
this.creatingGameSystem = false;
this.newGameSystemName = '';
}
submitCreateGameSystem(): void {
const name = this.newGameSystemName.trim();
if (!name || this.creatingGameSystemInFlight) return;
this.creatingGameSystemInFlight = true;
this.gameSystemService.create({ name, isPublic: false }).subscribe({
next: (created) => {
this.creatingGameSystemInFlight = false;
this.availableGameSystems = [...this.availableGameSystems, created];
if (created.id) {
this.form.get('gameSystemId')?.setValue(created.id);
}
this.creatingGameSystem = false;
this.newGameSystemName = '';
},
error: () => {
this.creatingGameSystemInFlight = false;
console.error('Erreur lors de la creation du systeme de jeu');
}
});
}
submit(): void {

View File

@@ -55,10 +55,37 @@
</div>
<div class="field">
<label>Système de JDR</label>
<select [(ngModel)]="editGameSystemId" name="editGameSystemId">
<select *ngIf="!creatingGameSystem"
[(ngModel)]="editGameSystemId"
name="editGameSystemId"
(ngModelChange)="onEditGameSystemChange($event)">
<option value="">— Aucun (générique) —</option>
<option *ngFor="let gs of availableGameSystems" [value]="gs.id">{{ gs.name }}</option>
<option [value]="CREATE_GAMESYSTEM_SENTINEL">+ Créer un nouveau système…</option>
</select>
<div *ngIf="creatingGameSystem" class="inline-create">
<input
type="text"
[(ngModel)]="newGameSystemName"
name="newGameSystemName"
placeholder="Nom du nouveau système (ex: D&D 5e, Nimble, Maison)"
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
(keydown.escape)="cancelCreateGameSystem()"
autofocus
/>
<div class="inline-create-actions">
<button type="button" class="btn-inline-primary"
[disabled]="!newGameSystemName.trim() || creatingGameSystemInFlight"
(click)="submitCreateGameSystem()">
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
Créer
</button>
<button type="button" class="btn-inline-secondary" (click)="cancelCreateGameSystem()">
Annuler
</button>
</div>
</div>
</div>
<div class="header-actions">
<button type="button" class="btn-primary" (click)="saveEdit()" [disabled]="!editName.trim()">

View File

@@ -122,6 +122,64 @@
textarea { resize: vertical; }
}
.inline-create {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.875rem;
background: #0f172a;
border: 1px solid #1f2937;
border-left: 3px solid #6c63ff;
border-radius: 8px;
input {
width: 100%;
background: #0a1320;
border: 1px solid #1f2937;
border-radius: 6px;
padding: 0.5rem 0.75rem;
color: white;
font-size: 0.9rem;
outline: none;
&::placeholder { color: #4b5563; }
&:focus { border-color: #6c63ff; }
}
.inline-create-actions { display: flex; gap: 0.5rem; }
.btn-inline-primary {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.45rem 0.875rem;
background: #6c63ff;
color: white;
border: none;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
&:hover:not(:disabled) { background: #5b52e0; }
&:disabled { opacity: 0.4; cursor: not-allowed; }
}
.btn-inline-secondary {
padding: 0.45rem 0.875rem;
background: transparent;
color: #9ca3af;
border: 1px solid #374151;
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
transition: background 0.2s, color 0.2s;
&:hover { background: #1f2937; color: white; }
}
}
.header-actions { justify-content: flex-end; }
}
}

View File

@@ -2,7 +2,7 @@ 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 { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, User, Dices, Drama, Check } from 'lucide-angular';
import { Router, RouterLink } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { catchError, switchMap, filter, map } from 'rxjs/operators';
@@ -14,11 +14,12 @@ 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 { LayoutService } 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';
import { loadCampaignTreeData, buildCampaignSidebarConfig, CampaignTreeData } from '../../campaign-tree.helper';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
@Component({
selector: 'app-campaign-detail',
@@ -36,6 +37,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
readonly User = User;
readonly Dices = Dices;
readonly Drama = Drama;
readonly Check = Check;
campaign: Campaign | null = null;
arcs: Arc[] = [];
@@ -61,6 +63,13 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
editLoreId = '';
editGameSystemId = '';
/** Valeur sentinelle de l'option "Creer un systeme" dans le <select>. */
readonly CREATE_GAMESYSTEM_SENTINEL = '__create__';
/** Mode creation inline d'un GameSystem depuis le dropdown d'edition. */
creatingGameSystem = false;
newGameSystemName = '';
creatingGameSystemInFlight = false;
constructor(
private route: ActivatedRoute,
private router: Router,
@@ -70,7 +79,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
private characterService: CharacterService,
private npcService: NpcService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService
) {}
ngOnInit(): void {
@@ -241,24 +251,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
}
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'
});
this.layoutService.show(buildCampaignSidebarConfig(this.campaign!, allCampaigns, data, this.campaign!.id!));
}
// ─────────────── Édition / suppression de la Campagne ───────────────
@@ -283,16 +276,83 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
cancelEdit(): void {
this.editing = false;
this.creatingGameSystem = false;
this.newGameSystemName = '';
}
/** Detecte la selection de l'option sentinelle dans le <select> GameSystem. */
onEditGameSystemChange(value: string): void {
if (value === this.CREATE_GAMESYSTEM_SENTINEL) {
this.editGameSystemId = '';
this.startCreateGameSystem();
}
}
startCreateGameSystem(): void {
this.creatingGameSystem = true;
this.newGameSystemName = '';
}
cancelCreateGameSystem(): void {
this.creatingGameSystem = false;
this.newGameSystemName = '';
}
submitCreateGameSystem(): void {
const name = this.newGameSystemName.trim();
if (!name || this.creatingGameSystemInFlight) return;
this.creatingGameSystemInFlight = true;
this.gameSystemService.create({ name, isPublic: false }).subscribe({
next: (created) => {
this.creatingGameSystemInFlight = false;
this.availableGameSystems = [...this.availableGameSystems, created];
if (created.id) {
this.editGameSystemId = created.id;
}
this.creatingGameSystem = false;
this.newGameSystemName = '';
},
error: () => {
this.creatingGameSystemInFlight = false;
console.error('Erreur lors de la creation du systeme de jeu');
}
});
}
saveEdit(): void {
if (!this.campaign || !this.editName.trim()) return;
const newGameSystemId = this.editGameSystemId ? this.editGameSystemId : null;
const currentGameSystemId = this.campaign.gameSystemId ?? null;
const gameSystemChanged = newGameSystemId !== currentGameSystemId;
const hasSheets = this.characters.length > 0 || this.npcs.length > 0;
if (gameSystemChanged && hasSheets) {
const count = this.characters.length + this.npcs.length;
this.confirmDialog.confirm({
title: 'Changer le systeme de jeu ?',
message:
`Vous etes sur le point de changer le systeme de jeu de cette campagne. ` +
`Cela change egalement le template des fiches de PJ et PNJ.`,
details: [
`${count} fiche(s) existante(s) sont liees au template du systeme actuel.`,
`Leurs champs ne s'afficheront plus avec le nouveau systeme.`,
`Les donnees restent stockees : revenir a l'ancien systeme les rendra a nouveau visibles.`
],
confirmLabel: 'Changer quand meme',
variant: 'warning'
}).then(ok => { if (ok) this.persistEdit(newGameSystemId); });
return;
}
this.persistEdit(newGameSystemId);
}
private persistEdit(newGameSystemId: string | null): void {
if (!this.campaign) return;
this.campaignService.updateCampaign(this.campaign.id!, {
name: this.editName.trim(),
description: this.editDescription,
playerCount: this.campaign.playerCount ?? 0,
loreId: this.editLoreId ? this.editLoreId : null,
gameSystemId: this.editGameSystemId ? this.editGameSystemId : null
gameSystemId: newGameSystemId
}).subscribe({
next: (updated) => {
this.campaign = updated;
@@ -321,18 +381,22 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
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.');
const details: string[] = [];
if (parts.length) details.push(`Sera aussi supprime : ${parts.join(', ')}.`);
details.push('Cette action est irreversible.');
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')
this.confirmDialog.confirm({
title: 'Supprimer la campagne ?',
message: `Supprimer definitivement la campagne "${campaign.name}" ?`,
details,
confirmLabel: 'Supprimer',
variant: 'danger'
}).then(ok => {
if (!ok) return;
this.campaignService.deleteCampaign(campaign.id!).subscribe({
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')

View File

@@ -7,9 +7,8 @@ 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 { LayoutService } from '../../../services/layout.service';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
@@ -65,21 +64,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
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'
});
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
});
}
@@ -102,6 +87,9 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
this.layoutService.hide();
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement / la
// disparition de la sidebar lors des navigations internes a la section.
}
}

View File

@@ -9,16 +9,17 @@ 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 { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Campaign, Chapter } from '../../../services/campaign.model';
import { Chapter } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model';
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
import { 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';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
/**
* Écran de détail/modification d'un Chapitre.
@@ -71,7 +72,8 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
private npcService: NpcService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService
) {
this.form = this.fb.group({
name: ['', Validators.required],
@@ -130,21 +132,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
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'
});
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
});
}
@@ -169,10 +157,18 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
}
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')
this.confirmDialog.confirm({
title: 'Supprimer le chapitre',
message: `Supprimer le chapitre "${this.chapter?.name}" ?`,
details: ['Cette action est irréversible.'],
confirmLabel: 'Supprimer',
variant: 'danger'
}).then(ok => {
if (!ok) return;
this.campaignService.deleteChapter(this.chapterId).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId]),
error: () => console.error('Erreur lors de la suppression')
});
});
}
@@ -181,6 +177,9 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
this.layoutService.hide();
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement / la
// disparition de la sidebar lors des navigations internes a la section.
}
}

View File

@@ -371,6 +371,9 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
this.layoutService.hide();
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement / la
// disparition de la sidebar lors des navigations internes a la section.
}
}

View File

@@ -9,12 +9,13 @@ 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 { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Campaign, Chapter } from '../../../services/campaign.model';
import { Chapter } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model';
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
/**
* Écran de consultation d'un Chapitre (lecture seule).
@@ -49,7 +50,8 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
private npcService: NpcService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService
) {}
ngOnInit(): void {
@@ -86,20 +88,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
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'
});
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
});
}
@@ -128,18 +117,24 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
const chapter = this.chapter;
this.campaignService.getChapterDeletionImpact(chapter.id!).subscribe({
next: impact => {
const lines = [`Supprimer le chapitre "${chapter.name}" ?`];
const details: string[] = [];
if (impact.scenes > 0) {
lines.push('');
lines.push(`Cette action supprimera aussi : ${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}.`);
details.push(`Cette action supprimera aussi : ${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}.`);
}
lines.push('');
lines.push('Cette action est irréversible.');
details.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')
this.confirmDialog.confirm({
title: 'Supprimer le chapitre',
message: `Supprimer le chapitre "${chapter.name}" ?`,
details,
confirmLabel: 'Supprimer',
variant: 'danger'
}).then(ok => {
if (!ok) return;
this.campaignService.deleteChapter(chapter.id!).subscribe({
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')
@@ -147,6 +142,9 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
this.layoutService.hide();
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement / la
// disparition de la sidebar lors des navigations internes a la section.
}
}

View File

@@ -26,8 +26,9 @@
<div class="ce-form">
<div class="field">
<label>Nom du personnage *</label>
<label for="character-name">Nom du personnage *</label>
<input
id="character-name"
type="text"
[(ngModel)]="name"
name="name"

View File

@@ -6,10 +6,12 @@ import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lu
import { CharacterService } from '../../../services/character.service';
import { CampaignService } from '../../../services/campaign.service';
import { GameSystemService } from '../../../services/game-system.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { TemplateField } from '../../../services/template.model';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
import { SingleImagePickerComponent } from '../../../shared/single-image-picker/single-image-picker.component';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
/**
* Editeur plein ecran d'une fiche de personnage (PJ).
@@ -62,7 +64,9 @@ export class CharacterEditComponent implements OnInit {
private router: Router,
private service: CharacterService,
private campaignService: CampaignService,
private gameSystemService: GameSystemService
private gameSystemService: GameSystemService,
private campaignSidebar: CampaignSidebarService,
private confirmDialog: ConfirmDialogService
) {}
ngOnInit(): void {
@@ -72,6 +76,7 @@ export class CharacterEditComponent implements OnInit {
if (this.campaignId) {
this.loadTemplateForCampaign(this.campaignId);
this.campaignSidebar.show(this.campaignId);
}
if (this.characterId) {
@@ -106,6 +111,7 @@ export class CharacterEditComponent implements OnInit {
});
}
submit(): void {
if (!this.name.trim() || !this.campaignId) return;
const payload = {
@@ -117,21 +123,36 @@ export class CharacterEditComponent implements OnInit {
keyValueValues: this.keyValueValues,
campaignId: this.campaignId
};
const isCreation = !this.characterId;
const req = this.characterId
? this.service.update(this.characterId, { ...payload, id: this.characterId, order: this.order })
: this.service.create(payload);
req.subscribe({
next: () => this.back(),
next: (saved) => {
if (isCreation && saved.id) {
this.router.navigate(['/campaigns', this.campaignId, 'characters', saved.id]);
} else {
this.back();
}
},
error: () => console.error('Erreur sauvegarde Character')
});
}
deleteCharacter(): void {
if (!this.characterId) return;
if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irreversible.`)) return;
this.service.delete(this.characterId).subscribe({
next: () => this.back(),
error: () => console.error('Erreur suppression Character')
this.confirmDialog.confirm({
title: 'Supprimer la fiche ?',
message: `Supprimer la fiche de "${this.name}" ?`,
details: ['Cette action est irreversible.'],
confirmLabel: 'Supprimer',
variant: 'danger'
}).then(ok => {
if (!ok || !this.characterId) return;
this.service.delete(this.characterId).subscribe({
next: () => this.back(),
error: () => console.error('Erreur suppression Character')
});
});
}

View File

@@ -5,6 +5,7 @@ import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular'
import { CharacterService } from '../../../services/character.service';
import { CampaignService } from '../../../services/campaign.service';
import { GameSystemService } from '../../../services/game-system.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { TemplateField } from '../../../services/template.model';
import { Character } from '../../../services/character.model';
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
@@ -40,7 +41,8 @@ export class CharacterViewComponent implements OnInit {
private router: Router,
private service: CharacterService,
private campaignService: CampaignService,
private gameSystemService: GameSystemService
private gameSystemService: GameSystemService,
private campaignSidebar: CampaignSidebarService
) {}
ngOnInit(): void {
@@ -54,6 +56,7 @@ export class CharacterViewComponent implements OnInit {
});
}
if (this.campaignId) {
this.campaignSidebar.show(this.campaignId);
this.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
if (camp.gameSystemId) {
this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => {

View File

@@ -26,8 +26,9 @@
<div class="ne-form">
<div class="field">
<label>Nom du PNJ *</label>
<label for="npc-name">Nom du PNJ *</label>
<input
id="npc-name"
type="text"
[(ngModel)]="name"
name="name"

View File

@@ -6,10 +6,12 @@ import { LucideAngularModule, Save, ArrowLeft, Drama, Trash2, Sparkles } from 'l
import { NpcService } from '../../../services/npc.service';
import { CampaignService } from '../../../services/campaign.service';
import { GameSystemService } from '../../../services/game-system.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { TemplateField } from '../../../services/template.model';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
import { SingleImagePickerComponent } from '../../../shared/single-image-picker/single-image-picker.component';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
/**
* Editeur plein ecran d'une fiche de PNJ.
@@ -57,7 +59,9 @@ export class NpcEditComponent implements OnInit {
private router: Router,
private service: NpcService,
private campaignService: CampaignService,
private gameSystemService: GameSystemService
private gameSystemService: GameSystemService,
private campaignSidebar: CampaignSidebarService,
private confirmDialog: ConfirmDialogService
) {}
ngOnInit(): void {
@@ -67,6 +71,7 @@ export class NpcEditComponent implements OnInit {
if (this.campaignId) {
this.loadTemplateForCampaign(this.campaignId);
this.campaignSidebar.show(this.campaignId);
}
if (this.npcId) {
@@ -101,6 +106,7 @@ export class NpcEditComponent implements OnInit {
});
}
submit(): void {
if (!this.name.trim() || !this.campaignId) return;
const payload = {
@@ -112,21 +118,36 @@ export class NpcEditComponent implements OnInit {
keyValueValues: this.keyValueValues,
campaignId: this.campaignId
};
const isCreation = !this.npcId;
const req = this.npcId
? this.service.update(this.npcId, { ...payload, id: this.npcId, order: this.order })
: this.service.create(payload);
req.subscribe({
next: () => this.back(),
next: (saved) => {
if (isCreation && saved.id) {
this.router.navigate(['/campaigns', this.campaignId, 'npcs', saved.id]);
} else {
this.back();
}
},
error: () => console.error('Erreur sauvegarde Npc')
});
}
deleteNpc(): void {
if (!this.npcId) return;
if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irreversible.`)) return;
this.service.delete(this.npcId).subscribe({
next: () => this.back(),
error: () => console.error('Erreur suppression Npc')
this.confirmDialog.confirm({
title: 'Supprimer la fiche ?',
message: `Supprimer la fiche de "${this.name}" ?`,
details: ['Cette action est irreversible.'],
confirmLabel: 'Supprimer',
variant: 'danger'
}).then(ok => {
if (!ok || !this.npcId) return;
this.service.delete(this.npcId).subscribe({
next: () => this.back(),
error: () => console.error('Erreur suppression Npc')
});
});
}

View File

@@ -5,6 +5,7 @@ import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular'
import { NpcService } from '../../../services/npc.service';
import { CampaignService } from '../../../services/campaign.service';
import { GameSystemService } from '../../../services/game-system.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { TemplateField } from '../../../services/template.model';
import { Npc } from '../../../services/npc.model';
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
@@ -40,7 +41,8 @@ export class NpcViewComponent implements OnInit {
private router: Router,
private service: NpcService,
private campaignService: CampaignService,
private gameSystemService: GameSystemService
private gameSystemService: GameSystemService,
private campaignSidebar: CampaignSidebarService
) {}
ngOnInit(): void {
@@ -54,6 +56,7 @@ export class NpcViewComponent implements OnInit {
});
}
if (this.campaignId) {
this.campaignSidebar.show(this.campaignId);
this.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
if (camp.gameSystemId) {
this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => {

View File

@@ -7,9 +7,8 @@ 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 { LayoutService } from '../../../services/layout.service';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
@@ -67,21 +66,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
this.chapterName = currentChapter?.name ?? '';
this.existingSceneCount = treeData.scenesByChapter[this.chapterId]?.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'
});
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
});
}
@@ -94,7 +79,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
order: this.existingSceneCount + 1,
icon: this.selectedIcon
}).subscribe({
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', created.id, 'edit']),
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', created.id]),
error: () => console.error('Erreur lors de la création de la scène')
});
}
@@ -104,6 +89,9 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
this.layoutService.hide();
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement / la
// disparition de la sidebar lors des navigations internes a la section.
}
}

View File

@@ -9,17 +9,18 @@ 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 { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Campaign, Scene, SceneBranch } from '../../../services/campaign.model';
import { Scene, SceneBranch } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model';
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { ExpandableSectionComponent } from '../../../shared/expandable-section/expandable-section.component';
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';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
/**
* Écran de détail/modification d'une Scène.
@@ -75,7 +76,8 @@ export class SceneEditComponent implements OnInit, OnDestroy {
private npcService: NpcService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService
) {
this.form = this.fb.group({
name: ['', Validators.required],
@@ -155,21 +157,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
enemies: scene.enemies ?? ''
});
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'
});
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
});
}
@@ -200,10 +188,18 @@ export class SceneEditComponent implements OnInit, OnDestroy {
}
delete(): void {
if (!confirm(`Supprimer la scène "${this.scene?.name}" ? Cette action est irréversible.`)) return;
this.campaignService.deleteScene(this.sceneId).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId]),
error: () => console.error('Erreur lors de la suppression')
this.confirmDialog.confirm({
title: 'Supprimer la scène',
message: `Supprimer la scène "${this.scene?.name}" ?`,
details: ['Cette action est irréversible.'],
confirmLabel: 'Supprimer',
variant: 'danger'
}).then(ok => {
if (!ok) return;
this.campaignService.deleteScene(this.sceneId).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId]),
error: () => console.error('Erreur lors de la suppression')
});
});
}
@@ -236,6 +232,9 @@ export class SceneEditComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
this.layoutService.hide();
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement / la
// disparition de la sidebar lors des navigations internes a la section.
}
}

View File

@@ -9,12 +9,13 @@ 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 { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Campaign, Scene } from '../../../services/campaign.model';
import { Scene } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model';
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
/**
* Écran de consultation d'une Scène (lecture seule).
@@ -49,7 +50,8 @@ export class SceneViewComponent implements OnInit, OnDestroy {
private npcService: NpcService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService
) {}
ngOnInit(): void {
@@ -89,20 +91,7 @@ export class SceneViewComponent implements OnInit, OnDestroy {
this.availablePages = pages;
this.pageTitleService.set(scene.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'
});
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
});
}
@@ -121,16 +110,27 @@ export class SceneViewComponent implements OnInit, OnDestroy {
deleteScene(): void {
if (!this.scene) return;
const scene = this.scene;
if (!confirm(`Supprimer la scène "${scene.name}" ?\n\nCette action est irréversible.`)) return;
this.campaignService.deleteScene(scene.id!).subscribe({
next: () => this.router.navigate([
'/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId
]),
error: () => console.error('Erreur lors de la suppression de la scène')
this.confirmDialog.confirm({
title: 'Supprimer la scène',
message: `Supprimer la scène "${scene.name}" ?`,
details: ['Cette action est irréversible.'],
confirmLabel: 'Supprimer',
variant: 'danger'
}).then(ok => {
if (!ok) return;
this.campaignService.deleteScene(scene.id!).subscribe({
next: () => this.router.navigate([
'/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId
]),
error: () => console.error('Erreur lors de la suppression de la scène')
});
});
}
ngOnDestroy(): void {
this.layoutService.hide();
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement / la
// disparition de la sidebar lors des navigations internes a la section.
}
}