Co-MJ v1 : quêtes de première classe, guidage de préparation, assistant IA,
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m38s
Build & Push Images / build (web) (push) Successful in 1m53s
Build & Push Images / build (core) (push) Successful in 3m40s
Build & Push Images / build-switcher (push) Successful in 16s

mode séance, battlemaps multiples et export Foundry ciblé

- Quêtes (Niveau 1) : entité Quest orthogonale à l'arbre, rattachée à un arc HUB
  ou libre (migrations V9-V12, V18, V20 ; réconciliation des jumeaux V10).
  Fusion quête/conteneur dans la sidebar, progression par partie (statuts
  disponible/en cours/terminée), et quêtes libres avec espace de scènes créé
  automatiquement (arc technique « Quêtes libres », V21 : levée de la
  contrainte arcs.type héritée du baseline).
- Guidage (Pilier B) : bilan de préparation 100 % dérivé (règles arc/chapitre/
  scène/quête), panneau « Prochaines étapes » avec boutons « Corriger », et
  pastilles détaillées (tooltip des manques) dans l'arbre.
- Assistant IA (Pilier A) : étoffage champ par champ (scène, chapitre, arc) et
  brouillons de scènes en propose→applique (brain : narrative-fields,
  scene-drafts).
- Horloges & menaces (V15-V17) : clocks à segments avec déclencheurs, fronts.
- Mode séance : préparation de séance (readiness + quêtes dispo), scène
  épinglée (V19), récap « précédemment » (brain : session-recap), onglet
  « Partie » du panneau de référence, graphe amélioré (pan/zoom, éditeur de
  liens).
- Perf : endpoint agrégé GET /api/campaigns/{id}/tree — la sidebar charge en
  1 requête au lieu de ~15.
- Battlemaps multiples par scène (variantes jour/nuit, étages…) : liste JSON
  étiquetée (V22, reprise automatique de la carte existante), rendu PDF avec
  légendes, export/import rétro-compatible.
- Export Foundry ciblé : modale de périmètre (cartes+ennemis / journaux /
  tables), bundle filtré côté serveur (zip allégé), module Foundry à jour
  (respect du périmètre + une Scene Foundry par variante de carte,
  rétro-compatible anciens bundles).
This commit is contained in:
2026-07-03 15:29:23 +02:00
parent 268f4721ce
commit b2c3800bf8
278 changed files with 15570 additions and 1451 deletions

View File

@@ -54,6 +54,10 @@
</div>
</div>
}
<!-- ============ Guidage co-MJ : « Prochaines étapes » (Pilier B) ============ -->
@if (!editing && campaign.id) {
<app-readiness-panel [campaignId]="campaign.id" [assessment]="readiness"></app-readiness-panel>
}
<!-- ============ Header : mode édition inline ============ -->
@if (editing) {
<div class="detail-header edit-mode">
@@ -187,13 +191,19 @@
<div class="section-header">
<h2>{{ 'campaignDetail.arcsTitle' | translate }}</h2>
<div class="section-header-actions">
@if (showQuickAddScene) {
<button class="btn-add" (click)="quickAddScene()" [disabled]="quickSceneInFlight">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
{{ 'campaignDetail.quickScene' | translate }}
</button>
}
<button class="btn-add" (click)="createArc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
{{ 'campaignDetail.newArc' | translate }}
</button>
</div>
</div>
@if (arcs.length > 0) {
@if (arcs.length > 0 && !showQuickAddScene) {
<div class="arcs-grid" cdkDropList cdkDropListOrientation="mixed"
(cdkDropListDropped)="dropArc($event)">
@for (arc of arcs; track arc.id) {
@@ -205,13 +215,23 @@
}
</div>
}
<!-- Mode plat (un seul arc d'un seul chapitre) : on masque la carte de
l'arc par défaut, comme dans la sidebar. Les scènes vivent dans le volet. -->
@if (arcs.length > 0 && showQuickAddScene) {
<div class="empty-state empty-state--compact">
<p>{{ 'campaignDetail.flatModeHint' | translate }}</p>
</div>
}
@if (arcs.length === 0) {
<div class="empty-state">
<lucide-icon [img]="Swords" [size]="40" class="empty-icon"></lucide-icon>
<p>{{ 'campaignDetail.noArc' | translate }}</p>
<button class="btn-add-first" (click)="createArc()">
<button class="btn-add-first" (click)="quickAddScene()" [disabled]="quickSceneInFlight">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
{{ 'campaignDetail.createFirstArc' | translate }}
{{ 'campaignDetail.quickSceneFirst' | translate }}
</button>
<button type="button" class="empty-alt-action" (click)="createArc()">
{{ 'campaignDetail.organizeInArcs' | translate }}
</button>
</div>
}
@@ -277,3 +297,10 @@
(chapter-edit > « Conditions de déblocage »). -->
</div>
}
<!-- Choix du périmètre de l'export Foundry (tout / cartes+ennemis / journaux / tables). -->
<app-foundry-export-dialog
[open]="foundryDialogOpen"
[exporting]="exportingFoundry"
(cancelled)="foundryDialogOpen = false"
(confirmed)="onFoundryExportConfirmed($event)"></app-foundry-export-dialog>

View File

@@ -486,6 +486,19 @@
&:hover { background: #5b52e0; }
}
// Action secondaire de l'état vide (« ou créer un arc ») — bouton texte discret.
.empty-alt-action {
background: none;
border: none;
padding: 0;
color: #8a7bc8;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
&:hover { text-decoration: underline; }
}
// ─────────────── Sessions de jeu (Play Context) ───────────────
.sessions-grid {
display: grid;

View File

@@ -9,9 +9,9 @@ import { CdkDropList, CdkDrag, CdkDragDrop, moveItemInArray } from '@angular/cdk
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, Dices, Drama, Check, Play, Upload, Sparkles, Download, FileText, ChevronDown, ChevronRight, X } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { Router, RouterLink } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { forkJoin, of, Observable } from 'rxjs';
import { catchError, switchMap, filter, map } from 'rxjs/operators';
import { CampaignService } from '../../../services/campaign.service';
import { CampaignService, FoundryExportOptions } from '../../../services/campaign.service';
import { LoreService } from '../../../services/lore.service';
import { GameSystemService } from '../../../services/game-system.service';
import { GameSystem } from '../../../services/game-system.model';
@@ -30,10 +30,13 @@ import { Lore } from '../../../services/lore.model';
import { loadCampaignTreeData, buildCampaignSidebarConfig, CampaignTreeData } from '../../campaign-tree.helper';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
import { FolderGroup, groupByFolder, byOrder } from '../../../shared/folder-grouping.util';
import { CampaignReadinessAssessment } from '../../../services/readiness.model';
import { ReadinessPanelComponent } from '../../../shared/readiness-panel/readiness-panel.component';
import { FoundryExportDialogComponent } from '../foundry-export-dialog/foundry-export-dialog.component';
@Component({
selector: 'app-campaign-detail',
imports: [FormsModule, LucideAngularModule, RouterLink, TranslatePipe, CdkDropList, CdkDrag],
imports: [FormsModule, LucideAngularModule, RouterLink, TranslatePipe, CdkDropList, CdkDrag, ReadinessPanelComponent, FoundryExportDialogComponent],
templateUrl: './campaign-detail.component.html',
styleUrls: ['./campaign-detail.component.scss']
})
@@ -57,6 +60,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
/** Export Foundry en cours (anti double-clic). */
exportingFoundry = false;
/** Modale de périmètre de l'export Foundry. */
foundryDialogOpen = false;
/** Export PDF en cours (anti double-clic). */
exportingPdf = false;
@@ -100,6 +105,9 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
/** Parties (Playthroughs) de cette campagne. */
playthroughs: Playthrough[] = [];
/** Bilan de préparation (Pilier B — guidage) : alimente le panneau + les pastilles. */
readiness: CampaignReadinessAssessment | null = null;
/** Mode édition inline. */
editing = false;
editName = '';
@@ -139,7 +147,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
// on recharge les cartes pour rester cohérent sans rafraîchir la page.
this.dataSync.onChange(this.destroyRef, () => {
if (this.campaign?.id) {
this.campaignService.getArcs(this.campaign.id).subscribe(a => this.arcs = [...a].sort(byOrder));
this.campaignService.getArcs(this.campaign.id).subscribe(a =>
this.arcs = [...a].sort(byOrder).filter(x => x.type !== 'SYSTEM'));
this.loadNpcs(this.campaign.id); // PNJ regroupés/ordonnés
this.campaignSidebar.show(this.campaign.id); // recharge l'arbre aussi
}
@@ -193,11 +202,14 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
this.campaign = campaign;
this.editing = false;
this.playthroughs = playthroughs;
// Le bilan complet arrive avec l'arbre agrégé — plus de fetch séparé.
this.readiness = treeData.readiness ?? null;
this.loadLinkedLore(campaign);
this.loadLinkedGameSystem(campaign);
this.loadNpcs(campaign.id!);
this.loadSessions(campaign.id!);
this.arcs = [...treeData.arcs].sort(byOrder);
// L'arc SYSTEM (« Quêtes libres ») est de la plomberie : pas de carte d'arc pour lui.
this.arcs = [...treeData.arcs].sort(byOrder).filter(a => a.type !== 'SYSTEM');
this.chapterCountByArc = this.computeChapterCounts(treeData);
this.showLayout(allCampaigns, treeData);
this.pageTitleService.set(campaign.name);
@@ -315,11 +327,71 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
this.router.navigate(['/campaigns', this.campaign.id, 'arcs', 'create']);
}
/** Télécharge le bundle Foundry de la campagne via un lien temporaire. */
// ─────────────── Ajout rapide d'une scène (conteneur par défaut) ───────────────
// Niveau 0 : on ne force plus à créer un arc puis un chapitre avant de poser une
// scène. Si la campagne n'a pas encore de structure, on provisionne un
// « Arc principal / Chapitre 1 » implicite (masqué dans l'arbre tant qu'il est
// seul), puis on ouvre la création de scène. Visible seulement quand la
// structure est simple (vide, ou un seul arc d'au plus un chapitre).
quickSceneInFlight = false;
get showQuickAddScene(): boolean {
if (this.arcs.length === 0) return true;
if (this.arcs.length === 1) return (this.chapterCountByArc[this.arcs[0].id!] ?? 0) <= 1;
return false;
}
quickAddScene(): void {
if (!this.campaign || this.quickSceneInFlight) return;
this.quickSceneInFlight = true;
const campaignId = this.campaign.id!;
this.ensureDefaultChapter(campaignId).subscribe({
next: ({ arcId, chapterId }) => {
this.quickSceneInFlight = false;
this.router.navigate(['/campaigns', campaignId, 'arcs', arcId, 'chapters', chapterId, 'scenes', 'create']);
},
error: () => {
this.quickSceneInFlight = false;
console.error('Erreur lors de la préparation de la scène rapide');
}
});
}
/**
* Garantit l'existence d'un chapitre cible pour une scène : réutilise le premier
* arc / chapitre s'ils existent, sinon crée « Arc principal » puis « Chapitre 1 ».
*/
private ensureDefaultChapter(campaignId: string): Observable<{ arcId: string; chapterId: string }> {
const newChapter = (arcId: string) => this.campaignService.createChapter({
name: this.translate.instant('campaignDetail.defaultChapterName'),
description: '', arcId, order: 1, icon: null
}).pipe(map(ch => ({ arcId, chapterId: ch.id! })));
const firstArc = this.arcs[0];
if (!firstArc) {
return this.campaignService.createArc({
name: this.translate.instant('campaignDetail.defaultArcName'),
description: '', campaignId, order: 1, type: 'LINEAR', icon: null
}).pipe(switchMap(arc => newChapter(arc.id!)));
}
return this.campaignService.getChapters(firstArc.id!).pipe(
switchMap(chs => chs.length
? of({ arcId: firstArc.id!, chapterId: chs[0].id! })
: newChapter(firstArc.id!))
);
}
/** Ouvre la modale de choix du périmètre de l'export Foundry. */
exportFoundry(): void {
if (!this.campaign?.id || this.exportingFoundry) return;
this.foundryDialogOpen = true;
}
/** Télécharge le bundle Foundry (périmètre choisi) via un lien temporaire. */
onFoundryExportConfirmed(opts: FoundryExportOptions): void {
if (!this.campaign?.id || this.exportingFoundry) return;
this.exportingFoundry = true;
this.campaignService.exportFoundry(this.campaign.id).subscribe({
this.campaignService.exportFoundry(this.campaign.id, opts).subscribe({
next: (blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -333,11 +405,13 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
// Révocation différée : libère l'URL sans annuler le téléchargement en cours.
setTimeout(() => URL.revokeObjectURL(url), 1000);
this.exportingFoundry = false;
this.foundryDialogOpen = false;
},
error: (err) => {
// Ne pas avaler l'erreur en silence : visible en console pour diagnostic.
console.error('Échec de l\'export Foundry', err);
this.exportingFoundry = false;
this.foundryDialogOpen = false;
}
});
}
@@ -407,7 +481,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
}
private showLayout(allCampaigns: Campaign[], data: CampaignTreeData): void {
this.layoutService.show(buildCampaignSidebarConfig(this.campaign!, allCampaigns, data, this.campaign!.id!, this.translate));
this.layoutService.show(buildCampaignSidebarConfig(
this.campaign!, allCampaigns, data, this.campaign!.id!, this.translate, this.readiness?.gaps ?? []));
}
// ─────────────── Édition / suppression de la Campagne ───────────────

View File

@@ -0,0 +1,59 @@
@if (open) {
<div class="confirm-backdrop" (click)="onCancel()">
<div class="confirm-modal variant-info"
role="dialog"
aria-modal="true"
[attr.aria-label]="'foundryDialog.title' | translate"
(click)="$event.stopPropagation()">
<div class="confirm-header">
<div class="confirm-icon">
<lucide-icon [img]="Download" [size]="20"></lucide-icon>
</div>
<h2>{{ 'foundryDialog.title' | translate }}</h2>
<button type="button" class="btn-close" (click)="onCancel()" [attr.aria-label]="'common.close' | translate">
<lucide-icon [img]="X" [size]="18"></lucide-icon>
</button>
</div>
<div class="confirm-body">
<p class="confirm-message">{{ 'foundryDialog.intro' | translate }}</p>
<div class="fx-presets">
<button type="button" (click)="presetAll()">{{ 'foundryDialog.presetAll' | translate }}</button>
<button type="button" (click)="presetMapsOnly()">{{ 'foundryDialog.presetMapsOnly' | translate }}</button>
</div>
<label class="fx-option">
<input type="checkbox" [checked]="maps" (change)="maps = !maps" />
<span>
<strong>{{ 'foundryDialog.optMaps' | translate }}</strong>
<small>{{ 'foundryDialog.optMapsHint' | translate }}</small>
</span>
</label>
<label class="fx-option">
<input type="checkbox" [checked]="journals" (change)="journals = !journals" />
<span>
<strong>{{ 'foundryDialog.optJournals' | translate }}</strong>
<small>{{ 'foundryDialog.optJournalsHint' | translate }}</small>
</span>
</label>
<label class="fx-option">
<input type="checkbox" [checked]="tables" (change)="tables = !tables" />
<span>
<strong>{{ 'foundryDialog.optTables' | translate }}</strong>
<small>{{ 'foundryDialog.optTablesHint' | translate }}</small>
</span>
</label>
</div>
<div class="confirm-actions">
<button type="button" class="btn-secondary" (click)="onCancel()" [disabled]="exporting">
{{ 'common.cancel' | translate }}
</button>
<button type="button" class="btn-confirm" (click)="onConfirm()" [disabled]="nothingSelected || exporting">
{{ (exporting ? 'campaignDetail.foundryExporting' : 'foundryDialog.confirm') | translate }}
</button>
</div>
</div>
</div>
}

View File

@@ -0,0 +1,151 @@
// Même squelette visuel que confirm-dialog (backdrop/modal/header/actions),
// avec en plus les cases à cocher du périmètre et les raccourcis.
.confirm-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.confirm-modal {
background: #111827;
border: 1px solid #1f2937;
border-radius: 16px;
width: 100%;
max-width: 520px;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
overflow: hidden;
&.variant-info { border-top: 4px solid #6c63ff; }
}
.confirm-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #1f2937;
h2 {
flex: 1;
color: white;
font-size: 1.1rem;
font-weight: 600;
margin: 0;
}
}
.confirm-icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
flex-shrink: 0;
background: rgba(108, 99, 255, 0.15);
color: #6c63ff;
}
.btn-close {
background: transparent;
border: none;
color: #6b7280;
cursor: pointer;
padding: 0.25rem;
border-radius: 6px;
display: flex;
transition: color 0.2s;
&:hover { color: white; }
}
.confirm-body {
padding: 1.25rem 1.5rem;
color: #d1d5db;
font-size: 0.9rem;
line-height: 1.6;
}
.confirm-message { margin: 0 0 0.75rem; }
.fx-presets {
display: flex;
gap: 0.5rem;
margin-bottom: 0.9rem;
button {
background: #1a1a2e;
border: 1px solid #2a2a3d;
border-radius: 999px;
color: #9ca3af;
padding: 0.3rem 0.8rem;
font-size: 0.8rem;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
&:hover { color: #d1d5db; border-color: #6c63ff; }
}
}
.fx-option {
display: flex;
align-items: flex-start;
gap: 0.65rem;
padding: 0.55rem 0.6rem;
border: 1px solid #1f2937;
border-radius: 8px;
cursor: pointer;
& + & { margin-top: 0.5rem; }
input { margin-top: 0.2rem; accent-color: #6c63ff; }
span { display: flex; flex-direction: column; }
strong { color: white; font-size: 0.9rem; font-weight: 600; }
small { color: #9ca3af; font-size: 0.8rem; line-height: 1.4; }
}
.confirm-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
background: #0d121d;
border-top: 1px solid #1f2937;
}
.btn-secondary {
padding: 0.6rem 1.25rem;
background: #1f2937;
color: #d1d5db;
border: 1px solid #374151;
border-radius: 8px;
font-size: 0.875rem;
cursor: pointer;
transition: background 0.2s;
&:hover { background: #374151; }
&:disabled { opacity: 0.6; cursor: default; }
}
.btn-confirm {
padding: 0.6rem 1.25rem;
color: white;
border: none;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
background: #6c63ff;
&:hover { background: #5b52e0; }
&:disabled { opacity: 0.6; cursor: default; }
}

View File

@@ -0,0 +1,54 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { LucideAngularModule, Download, X } from 'lucide-angular';
import { TranslatePipe } from '@ngx-translate/core';
import { FoundryExportOptions } from '../../../services/campaign.service';
/**
* Modale de choix du PÉRIMÈTRE de l'export Foundry : tout, ou seulement les
* cartes + ennemis (usage table de jeu), ou seulement les journaux… Trois cases
* indépendantes + raccourcis. Le parent déclenche le téléchargement au confirm.
*/
@Component({
selector: 'app-foundry-export-dialog',
imports: [LucideAngularModule, TranslatePipe],
templateUrl: './foundry-export-dialog.component.html',
styleUrls: ['./foundry-export-dialog.component.scss']
})
export class FoundryExportDialogComponent {
readonly Download = Download;
readonly X = X;
@Input() open = false;
@Input() exporting = false;
@Output() cancelled = new EventEmitter<void>();
@Output() confirmed = new EventEmitter<FoundryExportOptions>();
maps = true;
journals = true;
tables = true;
get nothingSelected(): boolean { return !this.maps && !this.journals && !this.tables; }
/** Raccourci « usage table de jeu » : uniquement les cartes + ennemis. */
presetMapsOnly(): void {
this.maps = true;
this.journals = false;
this.tables = false;
}
presetAll(): void {
this.maps = true;
this.journals = true;
this.tables = true;
}
onCancel(): void {
if (this.exporting) return;
this.cancelled.emit();
}
onConfirm(): void {
if (this.nothingSelected || this.exporting) return;
this.confirmed.emit({ maps: this.maps, journals: this.journals, tables: this.tables });
}
}