Export/import par campagne + espace de jeu (v2), fix du nombre de joueurs, et ergonomie de jeu

Export / import de contenu (format v2)
- Export SÉLECTIF : on peut exporter une seule campagne (sa clôture : arcs →
  chapitres → scènes, PNJ, ennemis, catalogues, tables, système de jeu lié) au
  lieu de tout exporter. Options par export : inclure le lore, l'espace de jeu,
  les images. L'export global devient « Sauvegarde complète ».
- ESPACE DE JEU désormais exporté/importé : parties (Playthrough), séances,
  journal, flags, progression de quêtes, et feuilles de perso (Character.playthroughId
  préservé au lieu d'être annulé). Remapping complet des références à l'import.
- Format v2 rétro-compatible : un export v1 (sans section jeu) s'importe toujours
  (champs absents → null → traités comme vides).
- Correction d'un bug latent : collectReferencedStorageKeys traitait les références
  d'images comme des clés de stockage alors que ce sont des IDs → les binaires
  d'images n'étaient en réalité pas embarqués dans le zip. Résolution ID → clé.
- Images des Rooms d'une scène (illustration + plan) désormais collectées (elles
  étaient oubliées des deux collecteurs).
- Endpoint : GET /api/admin/data/export?campaignId=&includeLore=&includePlay=&includeImages=
  ; écran « Sauvegarde des données » avec choix campagne + cases à cocher.
- Tests : round-trip étendu au jeu + binaire image réel, export sélectif par
  campagne, import d'archive v1, et DataTransferController.

Correction : nombre de joueurs non persisté
- playerCount était saisi à la création mais jeté par le backend (absent du
  domaine/entité/DTO/mapper) → affichage « 0 joueurs ». Ajouté sur toute la stack
  + migration V7 (défaut 4 pour les campagnes existantes) + intégré à l'export.

Ergonomie de l'espace de jeu et des listes
- Description de campagne repliable (clamp 3 lignes + « Voir plus / moins »).
- Dossiers repliables (chevron + compteur) dans les listes PNJ et Ennemis, sur la
  page campagne ET les pages dédiées.
- Renommage en place des parties (« Mes parties ») via un crayon.
- Panneau de session : onglets sur 2 rangs (« Scènes » n'est plus rogné) ; scènes
  triées par ordre manuel ; arcs repliables (repliés par défaut) ; PJ/PNJ repliables
  et PNJ regroupés par dossier.
This commit is contained in:
2026-06-26 14:38:39 +02:00
parent c1e3a20e5a
commit f670735100
37 changed files with 1487 additions and 258 deletions

View File

@@ -5,7 +5,14 @@
<div class="detail-header">
<div class="header-texts">
<h1>{{ campaign.name }}</h1>
<p class="description">{{ campaign.description }}</p>
@if (campaign.description) {
<p class="description" [class.description--clamped]="!descExpanded">{{ campaign.description }}</p>
@if (campaign.description.length > 180) {
<button type="button" class="desc-toggle" (click)="descExpanded = !descExpanded">
{{ (descExpanded ? 'campaignDetail.showLess' : 'campaignDetail.showMore') | translate }}
</button>
}
}
<div class="meta">
<span class="badge">{{ 'campaignDetail.players' | translate:{ n: campaign.playerCount || 0 } }}</span>
<!-- Badge "Univers" : lien vers le Lore associé si présent -->
@@ -141,22 +148,26 @@
</div>
@if (npcs.length > 0) {
@for (g of npcGroups; track g.folder) {
@if (g.folder) {
<h4 class="persona-folder">{{ g.folder.split('/').join(' / ') }}</h4>
} @else if (npcGroups.length > 1) {
<h4 class="persona-folder persona-folder--none">{{ 'campaignTree.unclassified' | translate }}</h4>
@if (g.folder || npcGroups.length > 1) {
<button type="button" class="persona-folder" [class.persona-folder--none]="!g.folder" (click)="toggleNpcFolder(g.folder)">
<lucide-icon [img]="isNpcFolderCollapsed(g.folder) ? ChevronRight : ChevronDown" [size]="14"></lucide-icon>
{{ g.folder ? g.folder.split('/').join(' / ') : ('campaignTree.unclassified' | translate) }}
<span class="persona-folder-count">{{ g.items.length }}</span>
</button>
}
<div class="characters-grid">
@for (npc of g.items; track npc.id) {
<div class="character-card" (click)="viewNpc(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>
@if (!isNpcFolderCollapsed(g.folder)) {
<div class="characters-grid">
@for (npc of g.items; track npc.id) {
<div class="character-card" (click)="viewNpc(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>
}
</div>
}
}
}
@if (npcs.length === 0) {
@@ -222,15 +233,34 @@
@if (playthroughs.length > 0) {
<div class="playthroughs-grid">
@for (p of playthroughs; track p) {
<div class="playthrough-card"
(click)="openPlaythrough(p)">
<div class="playthrough-card" (click)="openPlaythrough(p)">
<lucide-icon [img]="Dices" [size]="22" class="playthrough-icon"></lucide-icon>
<div class="playthrough-info">
<span class="playthrough-name">{{ p.name }}</span>
@if (p.description) {
<span class="playthrough-meta">{{ p.description }}</span>
@if (editingPlaythroughId === p.id) {
<input class="playthrough-rename-input"
[(ngModel)]="editPlaythroughName"
(click)="$event.stopPropagation()"
(keydown.enter)="savePlaythroughRename(p)"
(keydown.escape)="cancelPlaythroughRename()" />
} @else {
<span class="playthrough-name">{{ p.name }}</span>
@if (p.description) {
<span class="playthrough-meta">{{ p.description }}</span>
}
}
</div>
@if (editingPlaythroughId === p.id) {
<button type="button" class="btn-icon" (click)="savePlaythroughRename(p); $event.stopPropagation()" [title]="'common.validate' | translate">
<lucide-icon [img]="Check" [size]="15"></lucide-icon>
</button>
<button type="button" class="btn-icon" (click)="cancelPlaythroughRename(); $event.stopPropagation()" [title]="'common.cancel' | translate">
<lucide-icon [img]="X" [size]="15"></lucide-icon>
</button>
} @else {
<button type="button" class="btn-icon playthrough-edit" (click)="startRenamePlaythrough(p, $event)" [title]="'common.rename' | translate">
<lucide-icon [img]="Pencil" [size]="15"></lucide-icon>
</button>
}
</div>
}
</div>

View File

@@ -38,6 +38,18 @@
flex-direction: column;
gap: 0.15rem;
min-width: 0;
flex: 1;
}
.playthrough-rename-input {
width: 100%;
font-weight: 600;
font-size: 0.95rem;
padding: 0.15rem 0.4rem;
border: 1px solid var(--color-primary, #2c6cd6);
border-radius: 6px;
background: var(--color-surface, #fff);
color: inherit;
}
.playthrough-name {
@@ -85,7 +97,29 @@
color: #6b7280;
font-size: 0.95rem;
line-height: 1.6;
margin-bottom: 0.4rem;
white-space: pre-wrap;
// Replié : on borne à 3 lignes avec ellipse ; déplié = texte intégral.
&--clamped {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
.desc-toggle {
background: none;
border: none;
padding: 0;
margin-bottom: 1rem;
color: #8a7bc8;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
&:hover { text-decoration: underline; }
}
.meta {
@@ -508,12 +542,25 @@
// En-tête de dossier dans la grille des PNJ (regroupement par dossier).
.persona-folder {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #8a7bc8;
margin: 0.9rem 0 0.4rem;
// En-tête de dossier cliquable (repli) : reset du <button>.
background: none; border: none; padding: 0; cursor: pointer; text-align: left;
&:hover { color: #a594e0; }
.persona-folder-count {
font-weight: 400; text-transform: none; letter-spacing: 0;
color: #6b7280;
}
&--none { color: #6b7280; }
}

View File

@@ -6,7 +6,7 @@ import { CampaignSidebarService } from '../../../services/campaign-sidebar.servi
import { ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { CdkDropList, CdkDrag, CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, Dices, Drama, Check, Play, Upload, Sparkles, Download, FileText } from 'lucide-angular';
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';
@@ -51,6 +51,9 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
readonly Sparkles = Sparkles;
readonly Download = Download;
readonly FileText = FileText;
readonly ChevronDown = ChevronDown;
readonly ChevronRight = ChevronRight;
readonly X = X;
/** Export Foundry en cours (anti double-clic). */
exportingFoundry = false;
@@ -73,6 +76,16 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
npcs: Npc[] = [];
/** PNJ groupés par dossier (dossiers alpha, ordre manuel dans chacun) — comme la sidebar. */
npcGroups: FolderGroup<Npc>[] = [];
/** En-tête : description repliée par défaut (clamp), dépliable via « Voir plus ». */
descExpanded = false;
/** Dossiers de PNJ repliés (section Personnages) — par nom ('' = « Sans dossier »). */
collapsedNpcFolders = new Set<string>();
/** Renommage inline d'une partie : id en cours d'édition + valeur saisie. */
editingPlaythroughId: string | null = null;
editPlaythroughName = '';
/** Sessions de jeu (passées et en cours) liées à cette campagne. */
sessions: Session[] = [];
/**
@@ -234,6 +247,36 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
this.router.navigate(['/campaigns', this.campaign?.id, 'playthroughs', p.id]);
}
/** Démarre le renommage inline d'une partie (sans déclencher l'ouverture de la carte). */
startRenamePlaythrough(p: Playthrough, event: Event): void {
event.stopPropagation();
this.editingPlaythroughId = p.id ?? null;
this.editPlaythroughName = p.name;
}
savePlaythroughRename(p: Playthrough): void {
const name = this.editPlaythroughName.trim();
if (!name || !p.id) { this.editingPlaythroughId = null; return; }
this.playthroughService.update(p.id, { ...p, name }).subscribe({
next: updated => { p.name = updated.name; this.editingPlaythroughId = null; },
error: () => { this.editingPlaythroughId = null; }
});
}
cancelPlaythroughRename(): void {
this.editingPlaythroughId = null;
}
/** Replie / déplie un dossier de PNJ (section Personnages). */
toggleNpcFolder(folder: string): void {
if (this.collapsedNpcFolders.has(folder)) this.collapsedNpcFolders.delete(folder);
else this.collapsedNpcFolders.add(folder);
}
isNpcFolderCollapsed(folder: string): boolean {
return this.collapsedNpcFolders.has(folder);
}
/** Crée une nouvelle Partie avec un nom par défaut, puis y navigue. */
newPlaythroughInFlight = false;
createPlaythrough(): void {