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:
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -26,30 +26,37 @@
|
||||
}
|
||||
@for (g of groups; track g.folder; let gi = $index) {
|
||||
@if (g.folder) {
|
||||
<h2 class="sbl-folder">
|
||||
<button type="button" class="sbl-folder" (click)="toggleFolder(g.folder)">
|
||||
<lucide-icon class="sbl-folder-chevron" [img]="isCollapsed(g.folder) ? ChevronRight : ChevronDown" [size]="14"></lucide-icon>
|
||||
<lucide-icon [img]="Folder" [size]="14"></lucide-icon>
|
||||
{{ g.folder.split('/').join(' / ') }}
|
||||
<span class="sbl-folder-count">{{ g.items.length }}</span>
|
||||
</h2>
|
||||
</button>
|
||||
} @else if (groups.length > 1) {
|
||||
<h2 class="sbl-folder sbl-folder--none">{{ 'enemyList.unclassified' | translate }}</h2>
|
||||
<button type="button" class="sbl-folder sbl-folder--none" (click)="toggleFolder(g.folder)">
|
||||
<lucide-icon class="sbl-folder-chevron" [img]="isCollapsed(g.folder) ? ChevronRight : ChevronDown" [size]="14"></lucide-icon>
|
||||
{{ 'enemyList.unclassified' | translate }}
|
||||
<span class="sbl-folder-count">{{ g.items.length }}</span>
|
||||
</button>
|
||||
}
|
||||
@if (!isCollapsed(g.folder)) {
|
||||
<div class="sbl-group" cdkDropList [id]="'enemy-group-' + gi"
|
||||
[cdkDropListData]="g.items" [cdkDropListConnectedTo]="groupListIds"
|
||||
(cdkDropListDropped)="drop(g, $event)">
|
||||
@for (e of g.items; track e.id) {
|
||||
<button class="sbl-item" cdkDrag [cdkDragData]="e" (click)="open(e)">
|
||||
<lucide-icon [img]="Skull" [size]="16"></lucide-icon>
|
||||
<span class="sbl-item-name">{{ e.name }}</span>
|
||||
@if (e.level) {
|
||||
<span class="sbl-item-level">{{ 'enemyList.levelShort' | translate:{ level: e.level } }}</span>
|
||||
}
|
||||
<span class="sbl-del" (click)="remove(e, $event)" [title]="'common.delete' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="sbl-group" cdkDropList [id]="'enemy-group-' + gi"
|
||||
[cdkDropListData]="g.items" [cdkDropListConnectedTo]="groupListIds"
|
||||
(cdkDropListDropped)="drop(g, $event)">
|
||||
@for (e of g.items; track e.id) {
|
||||
<button class="sbl-item" cdkDrag [cdkDragData]="e" (click)="open(e)">
|
||||
<lucide-icon [img]="Skull" [size]="16"></lucide-icon>
|
||||
<span class="sbl-item-name">{{ e.name }}</span>
|
||||
@if (e.level) {
|
||||
<span class="sbl-item-level">{{ 'enemyList.levelShort' | translate:{ level: e.level } }}</span>
|
||||
}
|
||||
<span class="sbl-del" (click)="remove(e, $event)" [title]="'common.delete' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,9 +29,15 @@
|
||||
// En-tête de dossier (Démons, Humanoïdes…) — sépare visuellement les groupes.
|
||||
.sbl-folder {
|
||||
display: flex; align-items: center; gap: 0.4rem;
|
||||
margin: 0.9rem 0 0.15rem; font-size: 0.78rem; font-weight: 600;
|
||||
width: 100%; margin: 0.9rem 0 0.15rem; font-size: 0.78rem; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
color: #a5b4fc;
|
||||
// En-tête cliquable (repli) : on neutralise le style natif du <button>.
|
||||
background: none; border: none; padding: 0; cursor: pointer; font-family: inherit;
|
||||
|
||||
&:hover { color: #c7d2fe; }
|
||||
|
||||
.sbl-folder-chevron { opacity: 0.65; flex-shrink: 0; }
|
||||
|
||||
.sbl-folder-count {
|
||||
font-weight: 400; color: var(--color-text-muted, #9aa0aa);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { DestroyRef } from '@angular/core';
|
||||
import { CdkDropList, CdkDrag, CdkDragDrop, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
|
||||
import { DataSyncService } from '../../../services/data-sync.service';
|
||||
import { LucideAngularModule, ArrowLeft, Plus, Trash2, Skull, Folder, Upload } from 'lucide-angular';
|
||||
import { LucideAngularModule, ArrowLeft, Plus, Trash2, Skull, Folder, Upload, ChevronDown, ChevronRight } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||
@@ -30,6 +30,11 @@ export class EnemyListComponent implements OnInit {
|
||||
readonly Skull = Skull;
|
||||
readonly Folder = Folder;
|
||||
readonly Upload = Upload;
|
||||
readonly ChevronDown = ChevronDown;
|
||||
readonly ChevronRight = ChevronRight;
|
||||
|
||||
/** Dossiers repliés (par nom ; '' = « Sans dossier ») — persiste entre rechargements. */
|
||||
collapsedFolders = new Set<string>();
|
||||
|
||||
/** Import de monstres Foundry en cours (anti double-clic). */
|
||||
importing = false;
|
||||
@@ -74,6 +79,16 @@ export class EnemyListComponent implements OnInit {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'enemies', 'create']);
|
||||
}
|
||||
|
||||
/** Replie / déplie un dossier. */
|
||||
toggleFolder(folder: string): void {
|
||||
if (this.collapsedFolders.has(folder)) this.collapsedFolders.delete(folder);
|
||||
else this.collapsedFolders.add(folder);
|
||||
}
|
||||
|
||||
isCollapsed(folder: string): boolean {
|
||||
return this.collapsedFolders.has(folder);
|
||||
}
|
||||
|
||||
// --- Glisser-déposer (réordonner + déplacer entre dossiers) --------------
|
||||
|
||||
/** Ids des zones de dépôt (une par groupe/dossier) pour les connecter entre elles. */
|
||||
|
||||
@@ -22,27 +22,34 @@
|
||||
}
|
||||
@for (g of groups; track g.folder; let gi = $index) {
|
||||
@if (g.folder) {
|
||||
<h2 class="sbl-folder">
|
||||
<button type="button" class="sbl-folder" (click)="toggleFolder(g.folder)">
|
||||
<lucide-icon class="sbl-folder-chevron" [img]="isCollapsed(g.folder) ? ChevronRight : ChevronDown" [size]="14"></lucide-icon>
|
||||
<lucide-icon [img]="Folder" [size]="14"></lucide-icon>
|
||||
{{ g.folder }}
|
||||
<span class="sbl-folder-count">{{ g.items.length }}</span>
|
||||
</h2>
|
||||
</button>
|
||||
} @else if (groups.length > 1) {
|
||||
<h2 class="sbl-folder sbl-folder--none">{{ 'npcList.unclassified' | translate }}</h2>
|
||||
<button type="button" class="sbl-folder sbl-folder--none" (click)="toggleFolder(g.folder)">
|
||||
<lucide-icon class="sbl-folder-chevron" [img]="isCollapsed(g.folder) ? ChevronRight : ChevronDown" [size]="14"></lucide-icon>
|
||||
{{ 'npcList.unclassified' | translate }}
|
||||
<span class="sbl-folder-count">{{ g.items.length }}</span>
|
||||
</button>
|
||||
}
|
||||
@if (!isCollapsed(g.folder)) {
|
||||
<div class="sbl-group" cdkDropList [id]="'npc-group-' + gi"
|
||||
[cdkDropListData]="g.items" [cdkDropListConnectedTo]="groupListIds"
|
||||
(cdkDropListDropped)="drop(g, $event)">
|
||||
@for (n of g.items; track n.id) {
|
||||
<button class="sbl-item" cdkDrag [cdkDragData]="n" (click)="open(n)">
|
||||
<lucide-icon [img]="Drama" [size]="16"></lucide-icon>
|
||||
<span class="sbl-item-name">{{ n.name }}</span>
|
||||
<span class="sbl-del" (click)="remove(n, $event)" [title]="'common.delete' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="sbl-group" cdkDropList [id]="'npc-group-' + gi"
|
||||
[cdkDropListData]="g.items" [cdkDropListConnectedTo]="groupListIds"
|
||||
(cdkDropListDropped)="drop(g, $event)">
|
||||
@for (n of g.items; track n.id) {
|
||||
<button class="sbl-item" cdkDrag [cdkDragData]="n" (click)="open(n)">
|
||||
<lucide-icon [img]="Drama" [size]="16"></lucide-icon>
|
||||
<span class="sbl-item-name">{{ n.name }}</span>
|
||||
<span class="sbl-del" (click)="remove(n, $event)" [title]="'common.delete' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,9 +26,15 @@
|
||||
// En-tête de dossier (Démons, Humanoïdes…) — sépare visuellement les groupes.
|
||||
.sbl-folder {
|
||||
display: flex; align-items: center; gap: 0.4rem;
|
||||
margin: 0.9rem 0 0.15rem; font-size: 0.78rem; font-weight: 600;
|
||||
width: 100%; margin: 0.9rem 0 0.15rem; font-size: 0.78rem; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
color: #a5b4fc;
|
||||
// En-tête cliquable (repli) : on neutralise le style natif du <button>.
|
||||
background: none; border: none; padding: 0; cursor: pointer; font-family: inherit;
|
||||
|
||||
&:hover { color: #c7d2fe; }
|
||||
|
||||
.sbl-folder-chevron { opacity: 0.65; flex-shrink: 0; }
|
||||
|
||||
.sbl-folder-count {
|
||||
font-weight: 400; color: var(--color-text-muted, #9aa0aa);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { DestroyRef } from '@angular/core';
|
||||
import { CdkDropList, CdkDrag, CdkDragDrop, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
|
||||
import { DataSyncService } from '../../../services/data-sync.service';
|
||||
import { LucideAngularModule, ArrowLeft, Plus, Trash2, Drama, Folder } from 'lucide-angular';
|
||||
import { LucideAngularModule, ArrowLeft, Plus, Trash2, Drama, Folder, ChevronDown, ChevronRight } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||
@@ -29,6 +29,11 @@ export class NpcListComponent implements OnInit {
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Drama = Drama;
|
||||
readonly Folder = Folder;
|
||||
readonly ChevronDown = ChevronDown;
|
||||
readonly ChevronRight = ChevronRight;
|
||||
|
||||
/** Dossiers repliés (par nom ; '' = « Sans dossier ») — persiste entre rechargements. */
|
||||
collapsedFolders = new Set<string>();
|
||||
|
||||
campaignId = '';
|
||||
/** Groupes triés par nom de dossier ; les non-classés en dernier (folder = ''). */
|
||||
@@ -70,6 +75,16 @@ export class NpcListComponent implements OnInit {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'npcs', 'create']);
|
||||
}
|
||||
|
||||
/** Replie / déplie un dossier. */
|
||||
toggleFolder(folder: string): void {
|
||||
if (this.collapsedFolders.has(folder)) this.collapsedFolders.delete(folder);
|
||||
else this.collapsedFolders.add(folder);
|
||||
}
|
||||
|
||||
isCollapsed(folder: string): boolean {
|
||||
return this.collapsedFolders.has(folder);
|
||||
}
|
||||
|
||||
// --- Glisser-déposer (réordonner + déplacer entre dossiers) --------------
|
||||
|
||||
get groupListIds(): string[] {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, NgZone } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/**
|
||||
@@ -94,10 +94,22 @@ export class SettingsService {
|
||||
|
||||
// --- Export / Import des donnees (sauvegarde & transfert d'instance) -------
|
||||
|
||||
/** Telecharge l'export complet du contenu (zip : data.json + images). */
|
||||
exportData(): Observable<Blob> {
|
||||
/**
|
||||
* Télécharge un export (zip : data.json + images). Sans options : sauvegarde complète.
|
||||
* Avec `campaignId` : export ciblé d'une campagne, les options réglant l'univers (lore),
|
||||
* l'espace de jeu et les binaires d'images embarqués.
|
||||
*/
|
||||
exportData(opts?: { campaignId?: string; includeLore?: boolean; includePlay?: boolean; includeImages?: boolean }): Observable<Blob> {
|
||||
let params = new HttpParams();
|
||||
if (opts?.campaignId) {
|
||||
params = params
|
||||
.set('campaignId', opts.campaignId)
|
||||
.set('includeLore', String(opts.includeLore ?? true))
|
||||
.set('includePlay', String(opts.includePlay ?? true))
|
||||
.set('includeImages', String(opts.includeImages ?? true));
|
||||
}
|
||||
return this.http.get('/api/admin/data/export',
|
||||
{ withCredentials: true, responseType: 'blob' });
|
||||
{ params, withCredentials: true, responseType: 'blob' });
|
||||
}
|
||||
|
||||
/** Importe un zip d'export en mode FUSION (ajoute, ne remplace pas). */
|
||||
|
||||
@@ -93,35 +93,54 @@
|
||||
<div>
|
||||
@if (characters.length > 0) {
|
||||
<div class="ref-group">
|
||||
<h4>
|
||||
<button type="button" class="ref-group-head" (click)="pjCollapsed = !pjCollapsed">
|
||||
<lucide-icon class="ref-group-chevron" [img]="pjCollapsed ? ChevronRight : ChevronDown" [size]="12"></lucide-icon>
|
||||
<lucide-icon [img]="User" [size]="13"></lucide-icon>
|
||||
{{ 'sessionReferencePanel.playerCharacters' | translate }}
|
||||
</h4>
|
||||
@for (c of characters; track c) {
|
||||
<button
|
||||
type="button"
|
||||
class="ref-item"
|
||||
(click)="openInNewTab(['campaigns', campaignId, 'playthroughs', playthroughId!, 'characters', c.id!])">
|
||||
<span class="ref-item-name">{{ c.name }}</span>
|
||||
<lucide-icon [img]="ExternalLink" [size]="12" class="ref-item-icon"></lucide-icon>
|
||||
</button>
|
||||
<span class="ref-group-count">{{ characters.length }}</span>
|
||||
</button>
|
||||
@if (!pjCollapsed) {
|
||||
@for (c of characters; track c) {
|
||||
<button
|
||||
type="button"
|
||||
class="ref-item"
|
||||
(click)="openInNewTab(['campaigns', campaignId, 'playthroughs', playthroughId!, 'characters', c.id!])">
|
||||
<span class="ref-item-name">{{ c.name }}</span>
|
||||
<lucide-icon [img]="ExternalLink" [size]="12" class="ref-item-icon"></lucide-icon>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (npcs.length > 0) {
|
||||
<div class="ref-group">
|
||||
<h4>
|
||||
<button type="button" class="ref-group-head" (click)="pnjCollapsed = !pnjCollapsed">
|
||||
<lucide-icon class="ref-group-chevron" [img]="pnjCollapsed ? ChevronRight : ChevronDown" [size]="12"></lucide-icon>
|
||||
<lucide-icon [img]="Drama" [size]="13"></lucide-icon>
|
||||
{{ 'sessionReferencePanel.nonPlayerCharacters' | translate }}
|
||||
</h4>
|
||||
@for (n of npcs; track n) {
|
||||
<button
|
||||
type="button"
|
||||
class="ref-item"
|
||||
(click)="openInNewTab(['campaigns', campaignId, 'npcs', n.id!])">
|
||||
<span class="ref-item-name">{{ n.name }}</span>
|
||||
<lucide-icon [img]="ExternalLink" [size]="12" class="ref-item-icon"></lucide-icon>
|
||||
</button>
|
||||
<span class="ref-group-count">{{ npcs.length }}</span>
|
||||
</button>
|
||||
@if (!pnjCollapsed) {
|
||||
@for (fg of npcGroups; track fg.folder) {
|
||||
<div class="ref-subgroup">
|
||||
<button type="button" class="ref-subgroup-head" (click)="toggleNpcFolder(fg.folder)">
|
||||
<lucide-icon class="ref-group-chevron" [img]="isNpcFolderCollapsed(fg.folder) ? ChevronRight : ChevronDown" [size]="11"></lucide-icon>
|
||||
{{ fg.folder ? fg.folder.split('/').join(' / ') : ('campaignTree.unclassified' | translate) }}
|
||||
<span class="ref-group-count">{{ fg.items.length }}</span>
|
||||
</button>
|
||||
@if (!isNpcFolderCollapsed(fg.folder)) {
|
||||
@for (n of fg.items; track n) {
|
||||
<button
|
||||
type="button"
|
||||
class="ref-item ref-item--nested"
|
||||
(click)="openInNewTab(['campaigns', campaignId, 'npcs', n.id!])">
|
||||
<span class="ref-item-name">{{ n.name }}</span>
|
||||
<lucide-icon [img]="ExternalLink" [size]="12" class="ref-item-icon"></lucide-icon>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -147,25 +166,28 @@
|
||||
{{ 'sessionReferencePanel.emptyScenes' | translate }}
|
||||
</p>
|
||||
}
|
||||
@for (arc of treeData.arcs; track arc) {
|
||||
@for (arc of sortedArcs; track arc) {
|
||||
<div class="ref-group">
|
||||
<h4>
|
||||
<button type="button" class="ref-group-head" (click)="toggleArc(arc.id!)">
|
||||
<lucide-icon class="ref-group-chevron" [img]="isArcCollapsed(arc.id!) ? ChevronRight : ChevronDown" [size]="12"></lucide-icon>
|
||||
<lucide-icon [img]="Swords" [size]="13"></lucide-icon>
|
||||
{{ arc.name }}
|
||||
</h4>
|
||||
@for (chapter of chaptersOf(arc); track chapter) {
|
||||
<div class="ref-subgroup">
|
||||
<span class="ref-subgroup-title">{{ chapter.name }}</span>
|
||||
@for (scene of scenesOf(chapter); track scene) {
|
||||
<button
|
||||
type="button"
|
||||
class="ref-item ref-item--nested"
|
||||
(click)="openInNewTab(['campaigns', campaignId, 'arcs', arc.id!, 'chapters', chapter.id!, 'scenes', scene.id!])">
|
||||
<span class="ref-item-name">{{ scene.name }}</span>
|
||||
<lucide-icon [img]="ExternalLink" [size]="12" class="ref-item-icon"></lucide-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</button>
|
||||
@if (!isArcCollapsed(arc.id!)) {
|
||||
@for (chapter of chaptersOf(arc); track chapter) {
|
||||
<div class="ref-subgroup">
|
||||
<span class="ref-subgroup-title">{{ chapter.name }}</span>
|
||||
@for (scene of scenesOf(chapter); track scene) {
|
||||
<button
|
||||
type="button"
|
||||
class="ref-item ref-item--nested"
|
||||
(click)="openInNewTab(['campaigns', campaignId, 'arcs', arc.id!, 'chapters', chapter.id!, 'scenes', scene.id!])">
|
||||
<span class="ref-item-name">{{ scene.name }}</span>
|
||||
<lucide-icon [img]="ExternalLink" [size]="12" class="ref-item-icon"></lucide-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -12,21 +12,24 @@
|
||||
// ─────────────── Tabs ───────────────
|
||||
.ref-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap; // 6 onglets : on passe sur 2 rangs plutôt que de rogner « Scènes ».
|
||||
background: #111827;
|
||||
border-bottom: 1px solid #1f2937;
|
||||
}
|
||||
|
||||
.ref-tab {
|
||||
flex: 1;
|
||||
flex: 1 1 33.333%;
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
gap: 0.3rem;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
border: none;
|
||||
padding: 0.7rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.55rem 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
@@ -88,6 +91,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
// En-tête de section repliable (PJ / PNJ / arc), stylé comme un <h4> mais cliquable.
|
||||
.ref-group-head {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
margin: 0 0 0.25rem 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #9ca3af;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
text-align: left;
|
||||
|
||||
&:hover { color: #d1d5db; }
|
||||
|
||||
.ref-group-chevron { opacity: 0.6; flex-shrink: 0; }
|
||||
|
||||
.ref-group-count {
|
||||
margin-left: auto;
|
||||
font-weight: 400;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.ref-subgroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -100,6 +134,27 @@
|
||||
font-size: 0.72rem;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
// En-tête de dossier de PNJ (repliable) dans l'onglet Personnages.
|
||||
.ref-subgroup-head {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
width: 100%;
|
||||
margin-bottom: 0.15rem;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: #8a93a3;
|
||||
font-size: 0.72rem;
|
||||
|
||||
&:hover { color: #c0c7d1; }
|
||||
|
||||
.ref-group-chevron { opacity: 0.55; flex-shrink: 0; }
|
||||
.ref-group-count { margin-left: auto; color: #6b7280; }
|
||||
}
|
||||
}
|
||||
|
||||
.ref-item {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
|
||||
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { LucideAngularModule, User, Drama, Swords, Dices, ExternalLink, Sparkles, Table2, Package } from 'lucide-angular';
|
||||
import { LucideAngularModule, User, Drama, Swords, Dices, ExternalLink, Sparkles, Table2, Package, ChevronDown, ChevronRight } from 'lucide-angular';
|
||||
import { catchError, of } from 'rxjs';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
@@ -11,6 +11,7 @@ import { Character } from '../../services/character.model';
|
||||
import { Npc } from '../../services/npc.model';
|
||||
import { Arc, Chapter, Scene } from '../../services/campaign.model';
|
||||
import { loadCampaignTreeData, CampaignTreeData } from '../../campaigns/campaign-tree.helper';
|
||||
import { byOrder, groupByFolder, FolderGroup } from '../../shared/folder-grouping.util';
|
||||
import {
|
||||
SessionDicePanelComponent, DiceRollResult
|
||||
} from '../session-dice-panel/session-dice-panel.component';
|
||||
@@ -45,6 +46,8 @@ export class SessionReferencePanelComponent implements OnChanges {
|
||||
readonly Sparkles = Sparkles;
|
||||
readonly Table2 = Table2;
|
||||
readonly Package = Package;
|
||||
readonly ChevronDown = ChevronDown;
|
||||
readonly ChevronRight = ChevronRight;
|
||||
|
||||
@Input() campaignId!: string;
|
||||
/** Partie active — nécessaire pour charger les PJ (refonte Playthrough). */
|
||||
@@ -63,6 +66,14 @@ export class SessionReferencePanelComponent implements OnChanges {
|
||||
npcs: Npc[] = [];
|
||||
treeData: CampaignTreeData | null = null;
|
||||
|
||||
/** Sections PJ / PNJ repliables (onglet Personnages). */
|
||||
pjCollapsed = false;
|
||||
pnjCollapsed = false;
|
||||
/** Dossiers de PNJ repliés (onglet Personnages) — par nom ('' = « Sans dossier »). */
|
||||
collapsedNpcFolders = new Set<string>();
|
||||
/** Arcs repliés dans l'onglet Scènes — repliés par défaut pour une vue d'ensemble. */
|
||||
collapsedArcs = new Set<string>();
|
||||
|
||||
loadingChars = false;
|
||||
loadingTree = false;
|
||||
/** True dès qu'un tab "lourd" a été chargé pour éviter de rappeler l'API en boucle. */
|
||||
@@ -120,11 +131,42 @@ export class SessionReferencePanelComponent implements OnChanges {
|
||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, npcs: [], randomTables: [], enemies: [] } as CampaignTreeData))
|
||||
).subscribe(data => {
|
||||
this.treeData = data;
|
||||
// Arcs repliés par défaut : on présente une vue d'ensemble compacte, le MJ
|
||||
// déplie l'arc qui l'intéresse.
|
||||
this.collapsedArcs = new Set(data.arcs.map(a => a.id!).filter(Boolean) as string[]);
|
||||
this.loadingTree = false;
|
||||
this.treeLoaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
/** Arcs triés par ordre manuel (cohérent avec l'arbre et les cartes). */
|
||||
get sortedArcs(): Arc[] {
|
||||
return [...(this.treeData?.arcs ?? [])].sort(byOrder);
|
||||
}
|
||||
|
||||
toggleArc(id: string): void {
|
||||
if (this.collapsedArcs.has(id)) this.collapsedArcs.delete(id);
|
||||
else this.collapsedArcs.add(id);
|
||||
}
|
||||
|
||||
isArcCollapsed(id: string): boolean {
|
||||
return this.collapsedArcs.has(id);
|
||||
}
|
||||
|
||||
/** PNJ groupés par dossier (même classement que les vues cartes/sidebar). */
|
||||
get npcGroups(): FolderGroup<Npc>[] {
|
||||
return groupByFolder(this.npcs);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ouvre une fiche dans un nouvel onglet pour préserver l'écran de session.
|
||||
* Le MJ peut consulter sans perdre son journal ni son historique de dés.
|
||||
@@ -134,12 +176,12 @@ export class SessionReferencePanelComponent implements OnChanges {
|
||||
window.open('/' + url, '_blank', 'noopener');
|
||||
}
|
||||
|
||||
/** Helpers de typage pour le template (Angular n'infère pas bien sans). */
|
||||
/** Chapitres/scènes triés par ordre manuel (l'arbre brut n'est pas ordonné). */
|
||||
chaptersOf(arc: Arc): Chapter[] {
|
||||
return this.treeData?.chaptersByArc[arc.id!] ?? [];
|
||||
return [...(this.treeData?.chaptersByArc[arc.id!] ?? [])].sort(byOrder);
|
||||
}
|
||||
scenesOf(chapter: Chapter): Scene[] {
|
||||
return this.treeData?.scenesByChapter[chapter.id!] ?? [];
|
||||
return [...(this.treeData?.scenesByChapter[chapter.id!] ?? [])].sort(byOrder);
|
||||
}
|
||||
|
||||
onDiceRolled(result: DiceRollResult): void {
|
||||
|
||||
@@ -376,6 +376,27 @@
|
||||
<section class="card">
|
||||
<h2>{{ 'settings.data.title' | translate }}</h2>
|
||||
<p class="hint" [innerHTML]="'settings.data.hint' | translate"></p>
|
||||
<div class="export-scope">
|
||||
<label class="export-scope-row">
|
||||
<span>{{ 'settings.data.scopeLabel' | translate }}</span>
|
||||
<select [(ngModel)]="exportScope" [disabled]="exporting || importing">
|
||||
<option value="full">{{ 'settings.data.scopeFull' | translate }}</option>
|
||||
<optgroup [label]="'settings.data.scopeCampaignGroup' | translate">
|
||||
@for (c of campaigns; track c.id) {
|
||||
<option [value]="c.id">{{ c.name }}</option>
|
||||
}
|
||||
</optgroup>
|
||||
</select>
|
||||
</label>
|
||||
@if (exportScope !== 'full') {
|
||||
<div class="export-options">
|
||||
<span class="export-options-label">{{ 'settings.data.optionsLabel' | translate }}</span>
|
||||
<label><input type="checkbox" [(ngModel)]="exportIncludeLore"> {{ 'settings.data.optLore' | translate }}</label>
|
||||
<label><input type="checkbox" [(ngModel)]="exportIncludePlay"> {{ 'settings.data.optPlay' | translate }}</label>
|
||||
<label><input type="checkbox" [(ngModel)]="exportIncludeImages"> {{ 'settings.data.optImages' | translate }}</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="actions" appFileDrop [dropDisabled]="exporting || importing" (filesDropped)="onBackupDropped($event)">
|
||||
<button type="button" class="btn-secondary" (click)="exportData()" [disabled]="exporting || importing">
|
||||
<span>{{ (exporting ? 'settings.data.exporting' : 'settings.data.exportBtn') | translate }}</span>
|
||||
|
||||
@@ -286,6 +286,53 @@
|
||||
&:hover:not(:disabled) { background: rgba(255, 255, 255, 0.05); }
|
||||
}
|
||||
|
||||
.export-scope {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin: 4px 0 4px;
|
||||
}
|
||||
|
||||
.export-scope-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
|
||||
select {
|
||||
flex: 1;
|
||||
max-width: 340px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: inherit;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.export-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 18px;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
|
||||
.export-options-label {
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -6,6 +6,8 @@ import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Pl
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OpenRouterModel, MistralModel, GeminiModel, ImportResult } from '../services/settings.service';
|
||||
import { LayoutService } from '../services/layout.service';
|
||||
import { CampaignService } from '../services/campaign.service';
|
||||
import { Campaign } from '../services/campaign.model';
|
||||
import { UpdatesSectionComponent } from './updates-section/updates-section.component';
|
||||
import { OllamaModelManagerComponent } from './ollama-model-manager/ollama-model-manager.component';
|
||||
import { LanguageSwitcherComponent } from '../shared/language-switcher/language-switcher.component';
|
||||
@@ -97,7 +99,8 @@ export class SettingsComponent implements OnInit {
|
||||
private settingsService: SettingsService,
|
||||
private router: Router,
|
||||
private layoutService: LayoutService,
|
||||
private translate: TranslateService
|
||||
private translate: TranslateService,
|
||||
private campaignService: CampaignService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -105,6 +108,11 @@ export class SettingsComponent implements OnInit {
|
||||
// section precedente (cf. fix CampaignsComponent / LoreComponent).
|
||||
this.layoutService.hide();
|
||||
this.loadSettings();
|
||||
// Campagnes proposées pour un export ciblé (silencieux en cas d'échec).
|
||||
this.campaignService.getAllCampaigns().subscribe({
|
||||
next: (list) => this.campaigns = list,
|
||||
error: () => this.campaigns = []
|
||||
});
|
||||
}
|
||||
|
||||
loadSettings(): void {
|
||||
@@ -340,17 +348,33 @@ export class SettingsComponent implements OnInit {
|
||||
dataMessage = '';
|
||||
dataError = '';
|
||||
|
||||
/** Telecharge l'export complet (zip) via un lien temporaire. */
|
||||
/** Campagnes proposées pour un export ciblé. */
|
||||
campaigns: Campaign[] = [];
|
||||
/** Périmètre d'export : 'full' (sauvegarde complète) ou l'id d'une campagne. */
|
||||
exportScope = 'full';
|
||||
/** Options de l'export ciblé (ignorées en sauvegarde complète). */
|
||||
exportIncludeLore = true;
|
||||
exportIncludePlay = true;
|
||||
exportIncludeImages = true;
|
||||
|
||||
/** Télécharge l'export (complet ou ciblé selon le périmètre choisi) via un lien temporaire. */
|
||||
exportData(): void {
|
||||
this.exporting = true;
|
||||
this.dataMessage = '';
|
||||
this.dataError = '';
|
||||
this.settingsService.exportData().subscribe({
|
||||
const targeted = this.exportScope !== 'full';
|
||||
const opts = targeted ? {
|
||||
campaignId: this.exportScope,
|
||||
includeLore: this.exportIncludeLore,
|
||||
includePlay: this.exportIncludePlay,
|
||||
includeImages: this.exportIncludeImages
|
||||
} : undefined;
|
||||
this.settingsService.exportData(opts).subscribe({
|
||||
next: (blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'loremind-export.zip';
|
||||
a.download = targeted ? 'loremind-campagne.zip' : 'loremind-export.zip';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
this.exporting = false;
|
||||
|
||||
@@ -53,6 +53,13 @@
|
||||
"data": {
|
||||
"title": "Data backup",
|
||||
"hint": "Export all your content (game systems, lores, campaigns + images) to a file, to back it up or move it to another instance. Importing <strong>adds</strong> the content without deleting what's already there.",
|
||||
"scopeLabel": "Scope",
|
||||
"scopeFull": "Full backup (everything)",
|
||||
"scopeCampaignGroup": "Campaign",
|
||||
"optionsLabel": "Include",
|
||||
"optLore": "Lore (world)",
|
||||
"optPlay": "Play space (playthroughs, sessions, characters)",
|
||||
"optImages": "Images",
|
||||
"exportBtn": "Export data",
|
||||
"exporting": "Exporting…",
|
||||
"importBtn": "Import a file",
|
||||
@@ -568,6 +575,8 @@
|
||||
},
|
||||
"campaignDetail": {
|
||||
"players": "{{n}} players",
|
||||
"showMore": "Show more",
|
||||
"showLess": "Show less",
|
||||
"openLinkedLore": "Open the linked universe",
|
||||
"loreMissingTitle": "The linked universe cannot be found",
|
||||
"loreMissing": "Universe not found",
|
||||
|
||||
@@ -53,6 +53,13 @@
|
||||
"data": {
|
||||
"title": "Sauvegarde des données",
|
||||
"hint": "Exportez tout votre contenu (systèmes de jeu, lores, campagnes + images) dans un fichier, pour le sauvegarder ou le transférer vers une autre instance. L'import <strong>ajoute</strong> le contenu sans supprimer l'existant.",
|
||||
"scopeLabel": "Périmètre",
|
||||
"scopeFull": "Sauvegarde complète (tout)",
|
||||
"scopeCampaignGroup": "Campagne",
|
||||
"optionsLabel": "À inclure",
|
||||
"optLore": "Univers (lore)",
|
||||
"optPlay": "Espace de jeu (parties, sessions, persos)",
|
||||
"optImages": "Images",
|
||||
"exportBtn": "Exporter les données",
|
||||
"exporting": "Export en cours…",
|
||||
"importBtn": "Importer un fichier",
|
||||
@@ -568,6 +575,8 @@
|
||||
},
|
||||
"campaignDetail": {
|
||||
"players": "{{n}} joueurs",
|
||||
"showMore": "Voir plus",
|
||||
"showLess": "Voir moins",
|
||||
"openLinkedLore": "Ouvrir l'univers associé",
|
||||
"loreMissingTitle": "L'univers associé est introuvable",
|
||||
"loreMissing": "Univers introuvable",
|
||||
|
||||
Reference in New Issue
Block a user