Nettoyage post-revue : factorisation, code mort et fiabilité (reorder/lore/foundry/PDF)
All checks were successful
All checks were successful
Corrections fonctionnelles - order initialisé à la création des pages et dossiers de lore (nextOrderFor) : un nouvel élément se place désormais en dernier de sa fratrie au lieu de 0 - storePortrait : type MIME canonique dérivé de l'extension du fichier (un data URL "image/jpg" n'est plus rejeté silencieusement) - takeUntilDestroyed ajouté sur les abonnements paramMap de arc-view, folder-view et campaign-detail (fuites mémoire) Factorisation (suppression de duplication) - shared/folder-grouping.util.ts : groupByFolder + byOrder + byFolderName mutualisés entre npc-list, enemy-list, campaign-detail et la sidebar (folderChildren). Le tri des dossiers est désormais cohérent entre la sidebar et les vues cartes (insensible casse/accents partout) - DataSyncService.onChange()/persist() remplacent le câblage changed$ + reorder recopié dans 5 vues - campaign-detail : loadCampaignBundle()/applyCampaignBundle() éliminent le forkJoin dupliqué entre ngOnInit et reload - ReorderSupport (domain/shared) : squelette générique remplaçant les 8 boucles de réordonnancement copiées dans les services - FoundryExportService : GameSystem résolu une seule fois dans buildBundle - module Foundry : walkScalars mutualise la récursion de flattenStats et flattenStructure (comportements préservés) ; esc() de l'importer aligné sur foundry.utils.escapeHTML Suppression de code mort - characterService/characters retirés de loadCampaignTreeData et de ses 16 appelants (arguments, injections, imports et littéraux CampaignTreeData) - CSS orphelin : .tree-row.cdk-drop-list-receiving et .btn-back - BottomPanel.initiallyOpen (jamais lu) retiré de l'interface et de l'appelant - commentaire trompeur du PDF corrigé Tests - mise à jour de 3 tests obsolètes de campaign-tree.helper.spec qui vérifiaient encore l'ancien comportement (tri alphabétique, non-classés en enfants directs) → alignés sur le modèle actuel (tri par order + pseudo-dossier "Sans dossier")
This commit is contained in:
19
web/package-lock.json
generated
19
web/package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "1.0.0-beta",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^21.2.16",
|
||||
"@angular/cdk": "^21.2.14",
|
||||
"@angular/common": "^21.2.16",
|
||||
"@angular/compiler": "^21.2.16",
|
||||
"@angular/core": "^21.2.16",
|
||||
@@ -595,6 +596,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/cdk": {
|
||||
"version": "21.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.2.14.tgz",
|
||||
"integrity": "sha512-806REq/CLf37nEhmmd8Q+ILN8z/RVG2vk2n8YZ/4TdHpcBCi5ux4AxLbpMmduLwGPOzPagJ6ggRzE5fnX0rmcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parse5": "^8.0.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "^21.0.0 || ^22.0.0",
|
||||
"@angular/core": "^21.0.0 || ^22.0.0",
|
||||
"@angular/platform-browser": "^21.0.0 || ^22.0.0",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/cli": {
|
||||
"version": "21.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.14.tgz",
|
||||
@@ -11201,7 +11218,6 @@
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^8.0.0"
|
||||
@@ -11255,7 +11271,6 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^21.2.16",
|
||||
"@angular/cdk": "^21.2.14",
|
||||
"@angular/common": "^21.2.16",
|
||||
"@angular/compiler": "^21.2.16",
|
||||
"@angular/core": "^21.2.16",
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
[titleRoute]="config.titleRoute || null"
|
||||
[items]="config.items"
|
||||
[createActions]="config.createActions"
|
||||
[bottomPanel]="config.bottomPanel || null">
|
||||
[bottomPanel]="config.bottomPanel || null"
|
||||
[rootDropKinds]="config.rootDropKinds || null"
|
||||
[rootDropParentId]="config.rootDropParentId ?? null"
|
||||
[reorderContext]="config.reorderContext || null">
|
||||
</app-secondary-sidebar>
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule, BookOpen } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
@@ -40,7 +39,6 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
@@ -64,7 +62,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
|
||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||
this.existingArcCount = treeData.arcs.length;
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import { switchMap } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
@@ -77,7 +76,6 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
@@ -121,7 +119,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
arc: this.campaignService.getArcById(this.arcId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
|
||||
@@ -40,10 +40,12 @@
|
||||
</p>
|
||||
}
|
||||
@if (hubQuests.length > 0) {
|
||||
<div class="hub-quest-grid">
|
||||
@for (q of hubQuests; track q) {
|
||||
<div class="hub-quest-grid" cdkDropList cdkDropListOrientation="mixed"
|
||||
(cdkDropListDropped)="dropQuest($event)">
|
||||
@for (q of hubQuests; track q.id) {
|
||||
<button type="button"
|
||||
class="hub-quest-card"
|
||||
cdkDrag [cdkDragData]="q"
|
||||
(click)="openQuest(q)">
|
||||
<div class="hub-quest-card-head">
|
||||
@if (q.icon) {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
||||
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { CdkDropList, CdkDrag, CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||
import { DataSyncService } from '../../../services/data-sync.service';
|
||||
import { LucideAngularModule, Pencil, Trash2, AlertCircle } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { resolveCampaignIcon } from '../../campaign-icons';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
@@ -27,7 +29,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-arc-view',
|
||||
imports: [RouterModule, LucideAngularModule, ImageGalleryComponent, TranslatePipe],
|
||||
imports: [RouterModule, LucideAngularModule, ImageGalleryComponent, TranslatePipe, CdkDropList, CdkDrag],
|
||||
templateUrl: './arc-view.component.html',
|
||||
styleUrls: ['./arc-view.component.scss']
|
||||
})
|
||||
@@ -59,7 +61,6 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
@@ -67,11 +68,16 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private translate: TranslateService
|
||||
private translate: TranslateService,
|
||||
private dataSync: DataSyncService,
|
||||
private destroyRef: DestroyRef
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
this.dataSync.onChange(this.destroyRef, () => {
|
||||
if (this.campaignId && this.arcId) this.load();
|
||||
});
|
||||
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
|
||||
const newCampaignId = pm.get('campaignId')!;
|
||||
const newArcId = pm.get('arcId')!;
|
||||
if (newArcId !== this.arcId || newCampaignId !== this.campaignId) {
|
||||
@@ -87,7 +93,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
arc: this.campaignService.getArcById(this.arcId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
@@ -129,6 +135,15 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/** Réordonne les quêtes (chapitres) de l'arc par glisser-déposer. */
|
||||
dropQuest(event: CdkDragDrop<Chapter[]>): void {
|
||||
if (event.previousIndex === event.currentIndex) return;
|
||||
moveItemInArray(this.hubQuests, event.previousIndex, event.currentIndex);
|
||||
this.dataSync.persist(
|
||||
this.campaignService.reorderChapters(this.arcId, this.hubQuests.map(q => q.id!)),
|
||||
() => this.load());
|
||||
}
|
||||
|
||||
openQuest(q: Chapter): void {
|
||||
if (!q.id) return;
|
||||
this.router.navigate([
|
||||
|
||||
@@ -9,7 +9,7 @@ const t = { instant: (k: string) => k } as unknown as TranslateService;
|
||||
function data(partial: Partial<CampaignTreeData> = {}): CampaignTreeData {
|
||||
return {
|
||||
arcs: [], chaptersByArc: {}, scenesByChapter: {},
|
||||
characters: [], npcs: [], randomTables: [], enemies: [],
|
||||
npcs: [], randomTables: [], enemies: [],
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
@@ -23,15 +23,15 @@ describe('buildCampaignTree', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('trie les arcs en ordre NUMÉRIQUE naturel (1, 2, 10)', () => {
|
||||
it('trie les arcs par ordre manuel (champ order, réagencé au glisser-déposer)', () => {
|
||||
const arcs = [
|
||||
{ id: 'a', name: '10. Final' },
|
||||
{ id: 'b', name: '2. Voyage' },
|
||||
{ id: 'c', name: '1. Intro' },
|
||||
{ id: 'a', name: 'Final', order: 2 },
|
||||
{ id: 'b', name: 'Voyage', order: 1 },
|
||||
{ id: 'c', name: 'Intro', order: 0 },
|
||||
];
|
||||
const tree = buildCampaignTree('c1', data({ arcs: arcs as never }), t);
|
||||
const arcLabels = tree.filter((n) => n.id?.startsWith('arc-')).map((n) => n.label);
|
||||
expect(arcLabels).toEqual(['1. Intro', '2. Voyage', '10. Final']);
|
||||
expect(arcLabels).toEqual(['Intro', 'Voyage', 'Final']);
|
||||
});
|
||||
|
||||
it('imbrique chapitres et scènes et marque les chapitres à prérequis', () => {
|
||||
@@ -47,7 +47,7 @@ describe('buildCampaignTree', () => {
|
||||
expect(chapter.children![0].id).toBe('scene-s1');
|
||||
});
|
||||
|
||||
it('regroupe les PNJ par dossier et laisse les non classés à la racine', () => {
|
||||
it('regroupe les PNJ par dossier et place les non classés dans un pseudo-dossier', () => {
|
||||
const npcs = [
|
||||
{ id: 'n1', name: 'Alice', folder: 'Ville' },
|
||||
{ id: 'n2', name: 'Bob', folder: 'Ville' },
|
||||
@@ -58,15 +58,19 @@ describe('buildCampaignTree', () => {
|
||||
expect(npcsRoot.meta).toBe('3');
|
||||
const folder = npcsRoot.children!.find((c) => c.id === 'npc-folder-Ville')!;
|
||||
expect(folder.children!.map((c) => c.label)).toEqual(['Alice', 'Bob']);
|
||||
expect(npcsRoot.children!.some((c) => c.id === 'npc-n3')).toBe(true);
|
||||
// Les non classés vont dans un pseudo-dossier « Sans dossier » (et non en enfants
|
||||
// directs) : tous les dossiers restent FRÈRES → glisser-déposer inter-dossiers fiable.
|
||||
const none = npcsRoot.children!.find((c) => c.id === 'npc-folder-__none__')!;
|
||||
expect(none.children!.some((c) => c.id === 'npc-n3')).toBe(true);
|
||||
});
|
||||
|
||||
it("affiche le niveau de l'ennemi en méta", () => {
|
||||
const tree = buildCampaignTree('c', data({
|
||||
enemies: [{ id: 'e1', name: 'Gobelin', level: 3 }] as never,
|
||||
enemies: [{ id: 'e1', name: 'Gobelin', level: 3, folder: 'Cave' }] as never,
|
||||
}), t);
|
||||
const enemiesRoot = tree.find((n) => n.id === 'enemies-root')!;
|
||||
expect(enemiesRoot.children![0].meta).toBe('Niv. 3');
|
||||
const folder = enemiesRoot.children!.find((c) => c.id === 'enemy-folder-Cave')!;
|
||||
expect(folder.children![0].meta).toBe('Niv. 3');
|
||||
});
|
||||
|
||||
it('libelle « nouvelle quête » dans un arc HUB (vs « nouveau chapitre »)', () => {
|
||||
|
||||
@@ -2,13 +2,12 @@ import { Observable, forkJoin, of } from 'rxjs';
|
||||
import { switchMap, map } from 'rxjs/operators';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { CampaignService } from '../services/campaign.service';
|
||||
import { CharacterService } from '../services/character.service';
|
||||
import { NpcService } from '../services/npc.service';
|
||||
import { RandomTableService } from '../services/random-table.service';
|
||||
import { EnemyService } from '../services/enemy.service';
|
||||
import { TreeItem, SecondarySidebarConfig, GlobalItem } from '../services/layout.service';
|
||||
import { TreeItem, SecondarySidebarConfig, GlobalItem, ReorderKind } from '../services/layout.service';
|
||||
import { groupByFolder, byOrder } from '../shared/folder-grouping.util';
|
||||
import { Arc, Chapter, Scene, Campaign } from '../services/campaign.model';
|
||||
import { Character } from '../services/character.model';
|
||||
import { Npc } from '../services/npc.model';
|
||||
import { RandomTable } from '../services/random-table.model';
|
||||
import { Enemy } from '../services/enemy.model';
|
||||
@@ -26,7 +25,6 @@ export interface CampaignTreeData {
|
||||
arcs: Arc[];
|
||||
chaptersByArc: Record<string, Chapter[]>;
|
||||
scenesByChapter: Record<string, Scene[]>;
|
||||
characters: Character[];
|
||||
npcs: Npc[];
|
||||
randomTables: RandomTable[];
|
||||
enemies: Enemy[];
|
||||
@@ -35,7 +33,6 @@ export interface CampaignTreeData {
|
||||
export function loadCampaignTreeData(
|
||||
service: CampaignService,
|
||||
campaignId: string,
|
||||
characterService: CharacterService,
|
||||
npcService: NpcService,
|
||||
// Optionnel pour ne pas casser les ~15 appelants existants : si fourni, les
|
||||
// tables aléatoires sont chargées et apparaissent dans la sidebar.
|
||||
@@ -49,7 +46,6 @@ export function loadCampaignTreeData(
|
||||
// doivent passer par PlaythroughService et appeler characterService.getByPlaythrough).
|
||||
return forkJoin({
|
||||
arcs: service.getArcs(campaignId),
|
||||
characters: of([] as Character[]),
|
||||
npcs: npcService.getByCampaign(campaignId),
|
||||
randomTables: randomTableService
|
||||
? randomTableService.getByCampaign(campaignId).pipe(catchError(() => of([] as RandomTable[])))
|
||||
@@ -58,9 +54,9 @@ export function loadCampaignTreeData(
|
||||
? enemyService.getByCampaign(campaignId).pipe(catchError(() => of([] as Enemy[])))
|
||||
: of([] as Enemy[])
|
||||
}).pipe(
|
||||
switchMap(({ arcs, characters, npcs, randomTables, enemies }) => {
|
||||
switchMap(({ arcs, npcs, randomTables, enemies }) => {
|
||||
if (arcs.length === 0) {
|
||||
return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters, npcs, randomTables, enemies });
|
||||
return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, npcs, randomTables, enemies });
|
||||
}
|
||||
const chapterCalls = arcs.map(a =>
|
||||
service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters })))
|
||||
@@ -75,7 +71,7 @@ export function loadCampaignTreeData(
|
||||
});
|
||||
|
||||
if (allChapters.length === 0) {
|
||||
return of({ arcs, chaptersByArc, scenesByChapter: {}, characters, npcs, randomTables, enemies });
|
||||
return of({ arcs, chaptersByArc, scenesByChapter: {}, npcs, randomTables, enemies });
|
||||
}
|
||||
const sceneCalls = allChapters.map(c =>
|
||||
service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes })))
|
||||
@@ -84,7 +80,7 @@ export function loadCampaignTreeData(
|
||||
map(sceneResults => {
|
||||
const scenesByChapter: Record<string, Scene[]> = {};
|
||||
sceneResults.forEach(r => { scenesByChapter[r.chapterId] = r.scenes; });
|
||||
return { arcs, chaptersByArc, scenesByChapter, characters, npcs, randomTables, enemies };
|
||||
return { arcs, chaptersByArc, scenesByChapter, npcs, randomTables, enemies };
|
||||
})
|
||||
);
|
||||
})
|
||||
@@ -94,10 +90,25 @@ export function loadCampaignTreeData(
|
||||
}
|
||||
|
||||
export function buildCampaignTree(campaignId: string, data: CampaignTreeData, translate: TranslateService): TreeItem[] {
|
||||
// Tri FR avec `numeric: true` pour que "1. Intro", "2. Voyage", "10. Final" soient
|
||||
// classés 1, 2, 10 (et pas 1, 10, 2). `sensitivity: 'base'` ignore la casse.
|
||||
const byName = (a: { name: string }, b: { name: string }) =>
|
||||
a.name.localeCompare(b.name, 'fr', { numeric: true, sensitivity: 'base' });
|
||||
// Tri par ORDRE manuel (réagencé par glisser-déposer, persisté en base ; `byOrder`
|
||||
// et `groupByFolder` sont mutualisés avec les vues cartes). Repli sur 0 sinon.
|
||||
|
||||
/**
|
||||
* Nœuds-dossiers d'une collection ordonnable : un nœud dépliable par dossier
|
||||
* (+ un pseudo-dossier « Sans dossier »), via le regroupement partagé. Tous les
|
||||
* dossiers sont FRÈRES (jamais imbriqués) → glisser-déposer inter-dossiers fiable.
|
||||
*/
|
||||
const folderChildren = <T extends { folder?: string | null; order?: number }>(
|
||||
items: T[], kind: ReorderKind, toItem: (x: T) => TreeItem, idPrefix: string
|
||||
): TreeItem[] =>
|
||||
groupByFolder(items).map(g => ({
|
||||
id: g.folder ? `${idPrefix}-folder-${g.folder}` : `${idPrefix}-folder-__none__`,
|
||||
label: g.folder || translate.instant('campaignTree.unclassified'),
|
||||
iconKey: 'folder',
|
||||
children: g.items.map(toItem),
|
||||
meta: String(g.items.length),
|
||||
dropKinds: [kind], dropParentId: g.folder
|
||||
}));
|
||||
|
||||
// IDs préfixés par type pour éviter les collisions dans LayoutService.expanded
|
||||
// (chaque entité a sa propre séquence IDENTITY en base → arc.id=1 et chapter.id=1
|
||||
@@ -105,39 +116,15 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData, tr
|
||||
// Note refonte Playthrough : les PJ ne sont plus rattachés à la campagne mais
|
||||
// à une Partie (Playthrough). On ne les affiche donc plus dans la sidebar de
|
||||
// campagne — seuls les PNJ (donnée de scénario) restent sous "Personnages".
|
||||
const sortedNpcs = [...data.npcs].sort(byName);
|
||||
const sortedNpcs = [...data.npcs].sort(byOrder);
|
||||
const npcItem = (n: Npc): TreeItem => ({
|
||||
id: `npc-${n.id}`,
|
||||
label: n.name,
|
||||
route: `/campaigns/${campaignId}/npcs/${n.id}`
|
||||
route: `/campaigns/${campaignId}/npcs/${n.id}`,
|
||||
dragKind: 'npc', dragId: n.id
|
||||
});
|
||||
|
||||
// Regroupement par DOSSIER : un sous-nœud (dépliable) par dossier, puis les PNJ
|
||||
// non classés directement sous « PNJ ».
|
||||
const npcsByFolder = new Map<string, Npc[]>();
|
||||
const ungroupedNpcs: Npc[] = [];
|
||||
for (const n of sortedNpcs) {
|
||||
const f = (n.folder ?? '').trim();
|
||||
if (f) {
|
||||
if (!npcsByFolder.has(f)) npcsByFolder.set(f, []);
|
||||
npcsByFolder.get(f)!.push(n);
|
||||
} else {
|
||||
ungroupedNpcs.push(n);
|
||||
}
|
||||
}
|
||||
const npcFolderNodes: TreeItem[] = [...npcsByFolder.keys()]
|
||||
.sort((a, b) => a.localeCompare(b, 'fr', { sensitivity: 'base' }))
|
||||
.map(folder => {
|
||||
const items = npcsByFolder.get(folder)!.map(npcItem);
|
||||
return {
|
||||
id: `npc-folder-${folder}`,
|
||||
label: folder,
|
||||
iconKey: 'folder',
|
||||
children: items,
|
||||
meta: String(items.length)
|
||||
};
|
||||
});
|
||||
const npcChildren: TreeItem[] = [...npcFolderNodes, ...ungroupedNpcs.map(npcItem)];
|
||||
const npcChildren = folderChildren(sortedNpcs, 'npc', npcItem, 'npc');
|
||||
|
||||
const npcsNode: TreeItem = {
|
||||
id: 'npcs-root',
|
||||
@@ -161,51 +148,30 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData, tr
|
||||
|
||||
// --- Ennemis (bestiaire) : même structure que les PNJ — dossiers dépliables
|
||||
// dans la sidebar + libellé cliquable vers la page de liste.
|
||||
const sortedEnemies = [...(data.enemies ?? [])].sort(byName);
|
||||
const sortedEnemies = [...(data.enemies ?? [])].sort(byOrder);
|
||||
const enemyItem = (e: Enemy): TreeItem => ({
|
||||
id: `enemy-${e.id}`,
|
||||
label: e.name,
|
||||
route: `/campaigns/${campaignId}/enemies/${e.id}`,
|
||||
meta: e.level ? `Niv. ${e.level}` : undefined
|
||||
meta: e.level ? `Niv. ${e.level}` : undefined,
|
||||
dragKind: 'enemy', dragId: e.id
|
||||
});
|
||||
const enemiesByFolder = new Map<string, Enemy[]>();
|
||||
const ungroupedEnemies: Enemy[] = [];
|
||||
for (const e of sortedEnemies) {
|
||||
const f = (e.folder ?? '').trim();
|
||||
if (f) {
|
||||
if (!enemiesByFolder.has(f)) enemiesByFolder.set(f, []);
|
||||
enemiesByFolder.get(f)!.push(e);
|
||||
} else {
|
||||
ungroupedEnemies.push(e);
|
||||
}
|
||||
}
|
||||
const enemyFolderNodes: TreeItem[] = [...enemiesByFolder.keys()]
|
||||
.sort((a, b) => a.localeCompare(b, 'fr', { sensitivity: 'base' }))
|
||||
.map(folder => {
|
||||
const items = enemiesByFolder.get(folder)!.map(enemyItem);
|
||||
return {
|
||||
id: `enemy-folder-${folder}`,
|
||||
label: folder,
|
||||
iconKey: 'folder',
|
||||
children: items,
|
||||
meta: String(items.length)
|
||||
};
|
||||
});
|
||||
const enemyChildren: TreeItem[] = [...enemyFolderNodes, ...ungroupedEnemies.map(enemyItem)];
|
||||
const enemyChildren = folderChildren(sortedEnemies, 'enemy', enemyItem, 'enemy');
|
||||
|
||||
const sortedArcs = [...data.arcs].sort(byName);
|
||||
const sortedArcs = [...data.arcs].sort(byOrder);
|
||||
|
||||
const arcNodes: TreeItem[] = sortedArcs.map((arc, idx) => {
|
||||
const sortedChapters = [...(data.chaptersByArc[arc.id!] ?? [])].sort(byName);
|
||||
const sortedChapters = [...(data.chaptersByArc[arc.id!] ?? [])].sort(byOrder);
|
||||
|
||||
const chapterItems: TreeItem[] = sortedChapters.map(ch => {
|
||||
const sortedScenes = [...(data.scenesByChapter[ch.id!] ?? [])].sort(byName);
|
||||
const sortedScenes = [...(data.scenesByChapter[ch.id!] ?? [])].sort(byOrder);
|
||||
|
||||
const sceneItems: TreeItem[] = sortedScenes.map(sc => ({
|
||||
id: `scene-${sc.id}`,
|
||||
label: sc.name,
|
||||
iconKey: sc.icon ?? undefined,
|
||||
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}/scenes/${sc.id}`
|
||||
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}/scenes/${sc.id}`,
|
||||
dragKind: 'scene', dragId: sc.id
|
||||
}));
|
||||
return {
|
||||
id: `chapter-${ch.id}`,
|
||||
@@ -214,6 +180,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData, tr
|
||||
// Cadenas si le chapitre porte des conditions de déblocage (hub ou linéaire).
|
||||
meta: (ch.prerequisites?.length ?? 0) > 0 ? '🔒' : undefined,
|
||||
children: sceneItems,
|
||||
dragKind: 'chapter', dragId: ch.id, dropKinds: ['scene'], dropParentId: ch.id,
|
||||
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}`,
|
||||
createActions: [{
|
||||
id: `new-scene-${ch.id}`,
|
||||
@@ -228,6 +195,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData, tr
|
||||
label: arc.name,
|
||||
iconKey: arc.icon ?? undefined,
|
||||
children: chapterItems,
|
||||
dragKind: 'arc', dragId: arc.id, dropKinds: ['chapter'], dropParentId: arc.id,
|
||||
route: `/campaigns/${campaignId}/arcs/${arc.id}`,
|
||||
sectionHeaderBefore: idx === 0 ? translate.instant('campaignTree.sectionNarration') : undefined,
|
||||
|
||||
@@ -241,12 +209,13 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData, tr
|
||||
};
|
||||
});
|
||||
|
||||
const sortedTables = [...(data.randomTables ?? [])].sort(byName);
|
||||
const sortedTables = [...(data.randomTables ?? [])].sort(byOrder);
|
||||
const tableItems: TreeItem[] = sortedTables.map(t => ({
|
||||
id: `random-table-${t.id}`,
|
||||
label: t.name,
|
||||
iconKey: t.icon ?? 'dice',
|
||||
route: `/campaigns/${campaignId}/random-tables/${t.id}`
|
||||
route: `/campaigns/${campaignId}/random-tables/${t.id}`,
|
||||
dragKind: 'table', dragId: t.id
|
||||
}));
|
||||
|
||||
const tablesNode: TreeItem = {
|
||||
@@ -254,6 +223,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData, tr
|
||||
label: translate.instant('campaignTree.randomTables'),
|
||||
iconKey: 'dice',
|
||||
children: tableItems,
|
||||
dropKinds: ['table'], dropParentId: '',
|
||||
meta: tableItems.length ? String(tableItems.length) : undefined,
|
||||
sectionHeaderBefore: translate.instant('campaignTree.sectionTools'),
|
||||
createActions: [{
|
||||
@@ -340,6 +310,10 @@ export function buildCampaignSidebarConfig(
|
||||
],
|
||||
globalItems,
|
||||
globalBackLabel: translate.instant('campaignTree.allCampaigns'),
|
||||
globalBackRoute: '/campaigns'
|
||||
globalBackRoute: '/campaigns',
|
||||
// DnD : les arcs se réordonnent à la racine ; rechargement via le contexte campagne.
|
||||
rootDropKinds: ['arc'],
|
||||
rootDropParentId: campaignId,
|
||||
reorderContext: { scope: 'campaign', id: campaignId }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-secondary" (click)="exportPdf()" [disabled]="exportingPdf">
|
||||
<lucide-icon [img]="FileText" [size]="14"></lucide-icon>
|
||||
{{ (exportingPdf ? 'campaignDetail.pdfExporting' : 'campaignDetail.pdfExport') | translate }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="exportFoundry()" [disabled]="exportingFoundry">
|
||||
<lucide-icon [img]="Download" [size]="14"></lucide-icon>
|
||||
{{ (exportingFoundry ? 'campaignDetail.foundryExporting' : 'campaignDetail.foundryExport') | translate }}
|
||||
@@ -136,17 +140,24 @@
|
||||
</button>
|
||||
</div>
|
||||
@if (npcs.length > 0) {
|
||||
<div class="characters-grid">
|
||||
@for (npc of npcs; track npc) {
|
||||
<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>
|
||||
@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>
|
||||
}
|
||||
</div>
|
||||
<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>
|
||||
}
|
||||
}
|
||||
@if (npcs.length === 0) {
|
||||
<div class="empty-state empty-state--compact">
|
||||
@@ -172,9 +183,10 @@
|
||||
</div>
|
||||
</div>
|
||||
@if (arcs.length > 0) {
|
||||
<div class="arcs-grid">
|
||||
@for (arc of arcs; track arc) {
|
||||
<div class="arc-card" (click)="openArc(arc)">
|
||||
<div class="arcs-grid" cdkDropList cdkDropListOrientation="mixed"
|
||||
(cdkDropListDropped)="dropArc($event)">
|
||||
@for (arc of arcs; track arc.id) {
|
||||
<div class="arc-card" cdkDrag [cdkDragData]="arc" (click)="openArc(arc)">
|
||||
<lucide-icon [img]="Swords" [size]="24" class="arc-icon"></lucide-icon>
|
||||
<span class="arc-name">{{ arc.name }}</span>
|
||||
<span class="arc-meta">{{ 'campaignDetail.chapters' | translate:{ n: chapterCountByArc[arc.id!] || 0 } }}</span>
|
||||
|
||||
@@ -505,3 +505,15 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
// En-tête de dossier dans la grille des PNJ (regroupement par dossier).
|
||||
.persona-folder {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #8a7bc8;
|
||||
margin: 0.9rem 0 0.4rem;
|
||||
|
||||
&--none { color: #6b7280; }
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { DataSyncService } from '../../../services/data-sync.service';
|
||||
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, Dices, Drama, Check, Play, Upload, Sparkles, Download } from 'lucide-angular';
|
||||
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 { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
@@ -11,7 +15,6 @@ import { CampaignService } from '../../../services/campaign.service';
|
||||
import { LoreService } from '../../../services/lore.service';
|
||||
import { GameSystemService } from '../../../services/game-system.service';
|
||||
import { GameSystem } from '../../../services/game-system.model';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
@@ -26,10 +29,11 @@ import { Campaign, Arc } from '../../../services/campaign.model';
|
||||
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';
|
||||
|
||||
@Component({
|
||||
selector: 'app-campaign-detail',
|
||||
imports: [FormsModule, LucideAngularModule, RouterLink, TranslatePipe],
|
||||
imports: [FormsModule, LucideAngularModule, RouterLink, TranslatePipe, CdkDropList, CdkDrag],
|
||||
templateUrl: './campaign-detail.component.html',
|
||||
styleUrls: ['./campaign-detail.component.scss']
|
||||
})
|
||||
@@ -46,9 +50,12 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
readonly Upload = Upload;
|
||||
readonly Sparkles = Sparkles;
|
||||
readonly Download = Download;
|
||||
readonly FileText = FileText;
|
||||
|
||||
/** Export Foundry en cours (anti double-clic). */
|
||||
exportingFoundry = false;
|
||||
/** Export PDF en cours (anti double-clic). */
|
||||
exportingPdf = false;
|
||||
|
||||
campaign: Campaign | null = null;
|
||||
arcs: Arc[] = [];
|
||||
@@ -64,6 +71,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
linkedGameSystem: GameSystem | null = null;
|
||||
/** Fiches de personnages non-joueurs (PNJ) de la campagne. */
|
||||
npcs: Npc[] = [];
|
||||
/** PNJ groupés par dossier (dossiers alpha, ordre manuel dans chacun) — comme la sidebar. */
|
||||
npcGroups: FolderGroup<Npc>[] = [];
|
||||
/** Sessions de jeu (passées et en cours) liées à cette campagne. */
|
||||
sessions: Session[] = [];
|
||||
/**
|
||||
@@ -98,7 +107,6 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
private campaignService: CampaignService,
|
||||
private loreService: LoreService,
|
||||
private gameSystemService: GameSystemService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
@@ -107,37 +115,32 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private translate: TranslateService
|
||||
private translate: TranslateService,
|
||||
private dataSync: DataSyncService,
|
||||
private campaignSidebar: CampaignSidebarService,
|
||||
private destroyRef: DestroyRef
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Synchro temps réel : si l'ordre des arcs change ailleurs (arbre de la sidebar),
|
||||
// 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.loadNpcs(this.campaign.id); // PNJ regroupés/ordonnés
|
||||
this.campaignSidebar.show(this.campaign.id); // recharge l'arbre aussi
|
||||
}
|
||||
});
|
||||
|
||||
// switchMap annule automatiquement le load précédent si l'utilisateur
|
||||
// change de campagne avant que le forkJoin ne réponde — évite qu'une
|
||||
// réponse en retard écrase des données plus récentes (race condition).
|
||||
this.route.paramMap.pipe(
|
||||
map(pm => pm.get('id')),
|
||||
filter((id): id is string => !!id && id !== this.campaign?.id),
|
||||
switchMap(id => forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(id),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService, this.enemyService).pipe(
|
||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [], enemies: [] } as CampaignTreeData))
|
||||
),
|
||||
playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[])))
|
||||
}))
|
||||
).subscribe(({ campaign, allCampaigns, treeData, playthroughs }) => {
|
||||
this.campaign = campaign;
|
||||
this.editing = false;
|
||||
this.playthroughs = playthroughs;
|
||||
this.loadLinkedLore(campaign);
|
||||
this.loadLinkedGameSystem(campaign);
|
||||
this.loadNpcs(campaign.id!);
|
||||
this.loadSessions(campaign.id!);
|
||||
this.arcs = treeData.arcs;
|
||||
this.chapterCountByArc = this.computeChapterCounts(treeData);
|
||||
this.showLayout(allCampaigns, treeData);
|
||||
this.pageTitleService.set(campaign.name);
|
||||
});
|
||||
switchMap(id => this.loadCampaignBundle(id)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe(result => this.applyCampaignBundle(result));
|
||||
}
|
||||
|
||||
private computeChapterCounts(data: CampaignTreeData): Record<string, number> {
|
||||
@@ -154,28 +157,39 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
* veut rafraîchir même si l'ID n'a pas changé.
|
||||
*/
|
||||
private reload(id: string): void {
|
||||
forkJoin({
|
||||
this.loadCampaignBundle(id).subscribe(result => this.applyCampaignBundle(result));
|
||||
}
|
||||
|
||||
/** Charge en un seul forkJoin : la campagne, ses voisines, l'arbre et les parties. */
|
||||
private loadCampaignBundle(id: string) {
|
||||
return forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(id),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService, this.enemyService).pipe(
|
||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [], enemies: [] } as CampaignTreeData))
|
||||
treeData: loadCampaignTreeData(this.campaignService, id, this.npcService, this.randomTableService, this.enemyService).pipe(
|
||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, npcs: [], randomTables: [], enemies: [] } as CampaignTreeData))
|
||||
),
|
||||
playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[])))
|
||||
}).subscribe(({ campaign, allCampaigns, treeData, playthroughs }) => {
|
||||
this.campaign = campaign;
|
||||
this.editing = false;
|
||||
this.playthroughs = playthroughs;
|
||||
this.loadLinkedLore(campaign);
|
||||
this.loadLinkedGameSystem(campaign);
|
||||
this.loadNpcs(campaign.id!);
|
||||
this.loadSessions(campaign.id!);
|
||||
this.arcs = treeData.arcs;
|
||||
this.chapterCountByArc = this.computeChapterCounts(treeData);
|
||||
this.showLayout(allCampaigns, treeData);
|
||||
this.pageTitleService.set(campaign.name);
|
||||
});
|
||||
}
|
||||
|
||||
/** Applique le bundle chargé à l'état du composant (commun à ngOnInit et reload). */
|
||||
private applyCampaignBundle(
|
||||
{ campaign, allCampaigns, treeData, playthroughs }:
|
||||
{ campaign: Campaign; allCampaigns: Campaign[]; treeData: CampaignTreeData; playthroughs: Playthrough[] }
|
||||
): void {
|
||||
this.campaign = campaign;
|
||||
this.editing = false;
|
||||
this.playthroughs = playthroughs;
|
||||
this.loadLinkedLore(campaign);
|
||||
this.loadLinkedGameSystem(campaign);
|
||||
this.loadNpcs(campaign.id!);
|
||||
this.loadSessions(campaign.id!);
|
||||
this.arcs = [...treeData.arcs].sort(byOrder);
|
||||
this.chapterCountByArc = this.computeChapterCounts(treeData);
|
||||
this.showLayout(allCampaigns, treeData);
|
||||
this.pageTitleService.set(campaign.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge le Lore associé (si loreId présent). On swallow l'erreur :
|
||||
* si le Lore a été supprimé entre-temps, on affiche simplement "Univers introuvable".
|
||||
@@ -205,7 +219,10 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
private loadNpcs(campaignId: string): void {
|
||||
this.npcService.getByCampaign(campaignId).pipe(
|
||||
catchError(() => of([] as Npc[]))
|
||||
).subscribe(list => this.npcs = list);
|
||||
).subscribe(list => {
|
||||
this.npcs = list;
|
||||
this.npcGroups = groupByFolder(list);
|
||||
});
|
||||
}
|
||||
|
||||
// Sessions retirées de cette vue (vivent dans Playthrough Detail).
|
||||
@@ -282,6 +299,45 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
/** Génère et télécharge le livret PDF de la campagne. */
|
||||
exportPdf(): void {
|
||||
if (!this.campaign?.id || this.exportingPdf) return;
|
||||
this.exportingPdf = true;
|
||||
const name = this.campaign.name;
|
||||
this.campaignService.exportPdf(this.campaign.id).subscribe({
|
||||
next: (blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${(name || 'campagne').replace(/[^a-zA-Z0-9-_]+/g, '_')}.pdf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
this.exportingPdf = false;
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Échec de l\'export PDF', err);
|
||||
this.exportingPdf = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Réordonne les arcs par glisser-déposer et persiste le nouvel ordre. */
|
||||
dropArc(event: CdkDragDrop<Arc[]>): void {
|
||||
if (event.previousIndex === event.currentIndex) return;
|
||||
moveItemInArray(this.arcs, event.previousIndex, event.currentIndex);
|
||||
this.campaignService.reorderArcs(this.arcs.map(a => a.id!)).subscribe({
|
||||
next: () => this.dataSync.notify(),
|
||||
error: () => {
|
||||
if (this.campaign?.id) {
|
||||
this.campaignService.getArcs(this.campaign.id).subscribe(
|
||||
a => this.arcs = [...a].sort((x, y) => (x.order ?? 0) - (y.order ?? 0)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openArc(arc: Arc): void {
|
||||
if (!this.campaign || !arc.id) return;
|
||||
this.router.navigate(['/campaigns', this.campaign.id, 'arcs', arc.id]);
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { CampaignImportService } from '../../../services/campaign-import.service';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
@@ -106,7 +105,6 @@ export class CampaignImportComponent implements OnInit {
|
||||
private router: Router,
|
||||
private service: CampaignImportService,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
@@ -122,7 +120,7 @@ export class CampaignImportComponent implements OnInit {
|
||||
|
||||
// Pré-chargement de l'arborescence existante (pour fusionner à la revue).
|
||||
// En cas d'échec on dégrade : tout sera considéré comme nouveau.
|
||||
loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||
loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe(data => this.existingData = data);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
@@ -42,7 +41,6 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
@@ -65,7 +63,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
|
||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||
const currentArc = treeData.arcs.find(a => a.id === this.arcId);
|
||||
this.arcName = currentArc?.name ?? '';
|
||||
|
||||
@@ -7,7 +7,6 @@ import { switchMap } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
@@ -87,7 +86,6 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
@@ -133,7 +131,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
chapter: this.campaignService.getChapterById(this.chapterId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
|
||||
@@ -5,7 +5,6 @@ import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule, ArrowLeft } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
@@ -70,7 +69,6 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
@@ -94,7 +92,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
chapter: this.campaignService.getChapterById(this.chapterId),
|
||||
scenes: this.campaignService.getScenes(this.chapterId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
|
||||
}).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => {
|
||||
this.chapter = chapter;
|
||||
this.scenes = scenes;
|
||||
|
||||
@@ -7,7 +7,6 @@ import { LucideAngularModule, Pencil, Network, Trash2, Lock } from 'lucide-angul
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { resolveCampaignIcon } from '../../campaign-icons';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
@@ -51,7 +50,6 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
@@ -83,7 +81,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
chapter: this.campaignService.getChapterById(this.chapterId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
|
||||
@@ -24,28 +24,32 @@
|
||||
@if (total === 0) {
|
||||
<p class="empty">{{ 'enemyList.empty' | translate }}</p>
|
||||
}
|
||||
@for (g of groups; track g.folder) {
|
||||
@for (g of groups; track g.folder; let gi = $index) {
|
||||
@if (g.folder) {
|
||||
<h2 class="sbl-folder">
|
||||
<lucide-icon [img]="Folder" [size]="14"></lucide-icon>
|
||||
{{ g.folder.split('/').join(' / ') }}
|
||||
<span class="sbl-folder-count">{{ g.enemies.length }}</span>
|
||||
<span class="sbl-folder-count">{{ g.items.length }}</span>
|
||||
</h2>
|
||||
} @else if (groups.length > 1) {
|
||||
<h2 class="sbl-folder sbl-folder--none">{{ 'enemyList.unclassified' | translate }}</h2>
|
||||
}
|
||||
@for (e of g.enemies; track e.id) {
|
||||
<button class="sbl-item" (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 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>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
|
||||
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 { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
@@ -8,12 +11,7 @@ import { CampaignSidebarService } from '../../../services/campaign-sidebar.servi
|
||||
import { Enemy } from '../../../services/enemy.model';
|
||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||
import { FileDropDirective } from '../../../shared/file-drop.directive';
|
||||
|
||||
/** Groupe d'affichage : un dossier (« Démons »…) et ses ennemis. */
|
||||
interface FolderGroup {
|
||||
folder: string;
|
||||
enemies: Enemy[];
|
||||
}
|
||||
import { FolderGroup, groupByFolder } from '../../../shared/folder-grouping.util';
|
||||
|
||||
/**
|
||||
* Liste des ennemis d'une campagne, groupés par dossier (comme les PNJ).
|
||||
@@ -21,7 +19,7 @@ interface FolderGroup {
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-enemy-list',
|
||||
imports: [LucideAngularModule, TranslatePipe, FileDropDirective],
|
||||
imports: [LucideAngularModule, TranslatePipe, FileDropDirective, CdkDropList, CdkDrag],
|
||||
templateUrl: './enemy-list.component.html',
|
||||
styleUrls: ['./enemy-list.component.scss']
|
||||
})
|
||||
@@ -38,7 +36,7 @@ export class EnemyListComponent implements OnInit {
|
||||
|
||||
campaignId = '';
|
||||
/** Groupes triés par nom de dossier ; les non-classés en dernier (folder = ''). */
|
||||
groups: FolderGroup[] = [];
|
||||
groups: FolderGroup<Enemy>[] = [];
|
||||
total = 0;
|
||||
|
||||
constructor(
|
||||
@@ -47,7 +45,9 @@ export class EnemyListComponent implements OnInit {
|
||||
private service: EnemyService,
|
||||
private campaignSidebar: CampaignSidebarService,
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private translate: TranslateService
|
||||
private translate: TranslateService,
|
||||
private dataSync: DataSyncService,
|
||||
private destroyRef: DestroyRef
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -56,41 +56,44 @@ export class EnemyListComponent implements OnInit {
|
||||
this.campaignSidebar.show(this.campaignId);
|
||||
this.load();
|
||||
}
|
||||
// Synchro temps réel avec l'arbre (et inversement) : recharge la liste ET l'arbre.
|
||||
this.dataSync.onChange(this.destroyRef, () => {
|
||||
this.load();
|
||||
if (this.campaignId) this.campaignSidebar.show(this.campaignId);
|
||||
});
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.service.getByCampaign(this.campaignId).subscribe({
|
||||
next: (list) => this.groups = this.groupByFolder(list),
|
||||
next: (list) => { this.total = list.length; this.groups = groupByFolder(list); },
|
||||
error: () => this.groups = []
|
||||
});
|
||||
}
|
||||
|
||||
/** Même logique de groupement que les PNJ de la sidebar : dossiers triés, non-classés à la fin. */
|
||||
private groupByFolder(enemies: Enemy[]): FolderGroup[] {
|
||||
this.total = enemies.length;
|
||||
const sorted = [...enemies].sort((a, b) => a.name.localeCompare(b.name, 'fr'));
|
||||
const byFolder = new Map<string, Enemy[]>();
|
||||
const ungrouped: Enemy[] = [];
|
||||
for (const e of sorted) {
|
||||
const f = (e.folder ?? '').trim();
|
||||
if (f) {
|
||||
if (!byFolder.has(f)) byFolder.set(f, []);
|
||||
byFolder.get(f)!.push(e);
|
||||
} else {
|
||||
ungrouped.push(e);
|
||||
}
|
||||
}
|
||||
const groups: FolderGroup[] = [...byFolder.keys()]
|
||||
.sort((a, b) => a.localeCompare(b, 'fr'))
|
||||
.map(folder => ({ folder, enemies: byFolder.get(folder)! }));
|
||||
if (ungrouped.length) groups.push({ folder: '', enemies: ungrouped });
|
||||
return groups;
|
||||
}
|
||||
|
||||
create(): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'enemies', 'create']);
|
||||
}
|
||||
|
||||
// --- 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. */
|
||||
get groupListIds(): string[] {
|
||||
return this.groups.map((_, i) => 'enemy-group-' + i);
|
||||
}
|
||||
|
||||
/** Drop d'un ennemi : réordonne dans le dossier, ou le déplace vers un autre dossier. */
|
||||
drop(target: FolderGroup<Enemy>, event: CdkDragDrop<Enemy[]>): void {
|
||||
if (event.previousContainer === event.container) {
|
||||
if (event.previousIndex === event.currentIndex) return;
|
||||
moveItemInArray(target.items, event.previousIndex, event.currentIndex);
|
||||
} else {
|
||||
transferArrayItem(event.previousContainer.data, event.container.data,
|
||||
event.previousIndex, event.currentIndex);
|
||||
}
|
||||
const folder = target.folder || null;
|
||||
this.dataSync.persist(this.service.reorder(folder, target.items.map(e => e.id!)), () => this.load());
|
||||
}
|
||||
|
||||
/**
|
||||
* Importe un catalogue de monstres Foundry (.json exporté par le module) dans
|
||||
* le bestiaire. Upsert côté backend (dédup par référence).
|
||||
|
||||
@@ -6,7 +6,6 @@ import { LucideAngularModule, ArrowLeft, Upload, Trash2, Send, FileText, Loader,
|
||||
import { NotebookService } from '../../../services/notebook.service';
|
||||
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
import { NotebookArchive, NotebookDetail, NotebookSource, NotebookMessage } from '../../../services/notebook.model';
|
||||
@@ -66,7 +65,6 @@ export class NotebookDetailComponent implements OnInit {
|
||||
private service: NotebookService,
|
||||
private campaignSidebar: CampaignSidebarService,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private enemyService: EnemyService,
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
@@ -84,7 +82,7 @@ export class NotebookDetailComponent implements OnInit {
|
||||
}
|
||||
|
||||
private loadTree(): void {
|
||||
loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, undefined, this.enemyService)
|
||||
loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, undefined, this.enemyService)
|
||||
.subscribe({
|
||||
next: (data) => { this.arcs = data.arcs; this.chaptersByArc = data.chaptersByArc; },
|
||||
error: () => { /* cibles indisponibles : les cartes le signaleront */ }
|
||||
|
||||
@@ -20,25 +20,29 @@
|
||||
@if (total === 0) {
|
||||
<p class="empty">{{ 'npcList.empty' | translate }}</p>
|
||||
}
|
||||
@for (g of groups; track g.folder) {
|
||||
@for (g of groups; track g.folder; let gi = $index) {
|
||||
@if (g.folder) {
|
||||
<h2 class="sbl-folder">
|
||||
<lucide-icon [img]="Folder" [size]="14"></lucide-icon>
|
||||
{{ g.folder }}
|
||||
<span class="sbl-folder-count">{{ g.npcs.length }}</span>
|
||||
<span class="sbl-folder-count">{{ g.items.length }}</span>
|
||||
</h2>
|
||||
} @else if (groups.length > 1) {
|
||||
<h2 class="sbl-folder sbl-folder--none">{{ 'npcList.unclassified' | translate }}</h2>
|
||||
}
|
||||
@for (n of g.npcs; track n.id) {
|
||||
<button class="sbl-item" (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 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>
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
|
||||
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 { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||
import { Npc } from '../../../services/npc.model';
|
||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
/** Groupe d'affichage : un dossier (« Bard's Gate »…) et ses PNJ. */
|
||||
interface FolderGroup {
|
||||
folder: string;
|
||||
npcs: Npc[];
|
||||
}
|
||||
import { FolderGroup, groupByFolder } from '../../../shared/folder-grouping.util';
|
||||
|
||||
/**
|
||||
* Vue d'ensemble des PNJ d'une campagne, groupés par dossier — pendant « page »
|
||||
@@ -21,7 +19,7 @@ interface FolderGroup {
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-npc-list',
|
||||
imports: [LucideAngularModule, TranslatePipe],
|
||||
imports: [LucideAngularModule, TranslatePipe, CdkDropList, CdkDrag],
|
||||
templateUrl: './npc-list.component.html',
|
||||
styleUrls: ['./npc-list.component.scss']
|
||||
})
|
||||
@@ -34,7 +32,7 @@ export class NpcListComponent implements OnInit {
|
||||
|
||||
campaignId = '';
|
||||
/** Groupes triés par nom de dossier ; les non-classés en dernier (folder = ''). */
|
||||
groups: FolderGroup[] = [];
|
||||
groups: FolderGroup<Npc>[] = [];
|
||||
total = 0;
|
||||
|
||||
constructor(
|
||||
@@ -43,7 +41,9 @@ export class NpcListComponent implements OnInit {
|
||||
private service: NpcService,
|
||||
private campaignSidebar: CampaignSidebarService,
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private translate: TranslateService
|
||||
private translate: TranslateService,
|
||||
private dataSync: DataSyncService,
|
||||
private destroyRef: DestroyRef
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -52,41 +52,42 @@ export class NpcListComponent implements OnInit {
|
||||
this.campaignSidebar.show(this.campaignId);
|
||||
this.load();
|
||||
}
|
||||
// Recharge la liste ET l'arbre (synchro temps réel).
|
||||
this.dataSync.onChange(this.destroyRef, () => {
|
||||
this.load();
|
||||
if (this.campaignId) this.campaignSidebar.show(this.campaignId);
|
||||
});
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.service.getByCampaign(this.campaignId).subscribe({
|
||||
next: (list) => this.groups = this.groupByFolder(list),
|
||||
next: (list) => { this.total = list.length; this.groups = groupByFolder(list); },
|
||||
error: () => this.groups = []
|
||||
});
|
||||
}
|
||||
|
||||
/** Même logique de groupement que la sidebar : dossiers triés, non-classés à la fin. */
|
||||
private groupByFolder(npcs: Npc[]): FolderGroup[] {
|
||||
this.total = npcs.length;
|
||||
const sorted = [...npcs].sort((a, b) => a.name.localeCompare(b.name, 'fr'));
|
||||
const byFolder = new Map<string, Npc[]>();
|
||||
const ungrouped: Npc[] = [];
|
||||
for (const n of sorted) {
|
||||
const f = (n.folder ?? '').trim();
|
||||
if (f) {
|
||||
if (!byFolder.has(f)) byFolder.set(f, []);
|
||||
byFolder.get(f)!.push(n);
|
||||
} else {
|
||||
ungrouped.push(n);
|
||||
}
|
||||
}
|
||||
const groups: FolderGroup[] = [...byFolder.keys()]
|
||||
.sort((a, b) => a.localeCompare(b, 'fr'))
|
||||
.map(folder => ({ folder, npcs: byFolder.get(folder)! }));
|
||||
if (ungrouped.length) groups.push({ folder: '', npcs: ungrouped });
|
||||
return groups;
|
||||
}
|
||||
|
||||
create(): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'npcs', 'create']);
|
||||
}
|
||||
|
||||
// --- Glisser-déposer (réordonner + déplacer entre dossiers) --------------
|
||||
|
||||
get groupListIds(): string[] {
|
||||
return this.groups.map((_, i) => 'npc-group-' + i);
|
||||
}
|
||||
|
||||
drop(target: FolderGroup<Npc>, event: CdkDragDrop<Npc[]>): void {
|
||||
if (event.previousContainer === event.container) {
|
||||
if (event.previousIndex === event.currentIndex) return;
|
||||
moveItemInArray(target.items, event.previousIndex, event.currentIndex);
|
||||
} else {
|
||||
transferArrayItem(event.previousContainer.data, event.container.data,
|
||||
event.previousIndex, event.currentIndex);
|
||||
}
|
||||
const folder = target.folder || null;
|
||||
this.dataSync.persist(this.service.reorder(folder, target.items.map(n => n.id!)), () => this.load());
|
||||
}
|
||||
|
||||
open(n: Npc): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'npcs', n.id]);
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ export class PlaythroughDetailComponent implements OnInit, OnDestroy {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService),
|
||||
playthrough: this.playthroughService.getById(this.playthroughId),
|
||||
sessions: this.sessionService.getSessions(this.playthroughId).pipe(catchError(() => of([] as Session[]))),
|
||||
characters: this.characterService.getByPlaythrough(this.playthroughId).pipe(catchError(() => of([] as Character[]))),
|
||||
|
||||
@@ -5,7 +5,6 @@ import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule, ArrowLeft } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
@@ -37,7 +36,6 @@ export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
@@ -63,7 +61,7 @@ export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService),
|
||||
playthrough: this.playthroughService.getById(this.playthroughId)
|
||||
}).subscribe(({ campaign, allCampaigns, treeData, playthrough }) => {
|
||||
this.playthrough = playthrough;
|
||||
|
||||
@@ -6,7 +6,6 @@ import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
@@ -41,7 +40,6 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
@@ -65,7 +63,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
|
||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||
const currentChapter = (treeData.chaptersByArc[this.arcId] ?? []).find(c => c.id === this.chapterId);
|
||||
this.chapterName = currentChapter?.name ?? '';
|
||||
|
||||
@@ -8,7 +8,6 @@ import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { StoredFileService } from '../../../services/stored-file.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
@@ -97,7 +96,6 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private storedFileService: StoredFileService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
@@ -154,7 +152,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
scene: this.campaignService.getSceneById(this.sceneId),
|
||||
chapterScenes: this.campaignService.getScenes(this.chapterId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
|
||||
@@ -7,7 +7,6 @@ import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { resolveCampaignIcon } from '../../campaign-icons';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
@@ -51,7 +50,6 @@ export class SceneViewComponent implements OnInit, OnDestroy {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
@@ -86,7 +84,7 @@ export class SceneViewComponent implements OnInit, OnDestroy {
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
scene: this.campaignService.getSceneById(this.sceneId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
|
||||
@@ -48,9 +48,15 @@
|
||||
</button>
|
||||
</div>
|
||||
@if (subfolders.length > 0) {
|
||||
<div class="items-grid">
|
||||
@for (sub of subfolders; track sub) {
|
||||
<div class="node-card" (click)="navigateToSubfolder(sub.id!)">
|
||||
<div class="items-grid" cdkDropList cdkDropListOrientation="mixed"
|
||||
[cdkDropListData]="subfolders" [cdkDropListEnterPredicate]="acceptFolders"
|
||||
(cdkDropListDropped)="dropSubfolder($event)">
|
||||
@for (sub of subfolders; track sub.id) {
|
||||
<div class="node-card" cdkDrag [cdkDragData]="sub"
|
||||
cdkDropList [id]="'sub-' + sub.id" [cdkDropListData]="sub"
|
||||
[cdkDropListEnterPredicate]="acceptPages"
|
||||
(cdkDropListDropped)="dropPageIntoFolder(sub, $event)"
|
||||
(click)="navigateToSubfolder(sub.id!)">
|
||||
<lucide-icon [img]="Folder" [size]="24" class="node-icon"></lucide-icon>
|
||||
<span class="node-name">{{ sub.name }}</span>
|
||||
</div>
|
||||
@@ -73,9 +79,12 @@
|
||||
</button>
|
||||
</div>
|
||||
@if (pages.length > 0) {
|
||||
<div class="items-grid">
|
||||
@for (page of pages; track page) {
|
||||
<div class="node-card" (click)="navigateToPage(page.id!)">
|
||||
<div class="items-grid" cdkDropList cdkDropListOrientation="mixed"
|
||||
[cdkDropListData]="pages" [cdkDropListConnectedTo]="subfolderListIds"
|
||||
[cdkDropListEnterPredicate]="acceptPages"
|
||||
(cdkDropListDropped)="dropPage($event)">
|
||||
@for (page of pages; track page.id) {
|
||||
<div class="node-card" cdkDrag [cdkDragData]="page" (click)="navigateToPage(page.id!)">
|
||||
<lucide-icon [img]="FileText" [size]="24" class="node-icon"></lucide-icon>
|
||||
<span class="node-name">{{ page.title }}</span>
|
||||
</div>
|
||||
|
||||
@@ -152,3 +152,22 @@
|
||||
font-size: 0.9rem;
|
||||
padding: 1rem 0.5rem;
|
||||
}
|
||||
|
||||
// --- Glisser-déposer (CDK) -------------------------------------------------
|
||||
.node-card { cursor: grab; }
|
||||
.node-card.cdk-drag-dragging { cursor: grabbing; }
|
||||
|
||||
// Carte en cours de déplacement (clone qui suit le curseur).
|
||||
.cdk-drag-preview {
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
|
||||
border-color: #6c63ff !important;
|
||||
}
|
||||
// Emplacement d'origine pendant le drag (silhouette en pointillés).
|
||||
.cdk-drag-placeholder { opacity: 0.4; }
|
||||
.cdk-drag-animating { transition: transform 200ms cubic-bezier(0, 0, 0.2, 1); }
|
||||
.items-grid.cdk-drop-list-dragging .node-card:not(.cdk-drag-placeholder) {
|
||||
transition: transform 200ms cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
// Surbrillance d'un sous-dossier quand une page est glissée dessus.
|
||||
.node-card.cdk-drop-list-receiving,
|
||||
.node-card.cdk-drop-list-dragging { border-color: #6c63ff; background: rgba(108, 99, 255, 0.12); }
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Component, OnDestroy, OnInit, DestroyRef } from '@angular/core';
|
||||
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule, LucideIconData, Folder, FileText, Pencil, Trash2, Plus, ChevronRight } from 'lucide-angular';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { CdkDropList, CdkDrag, CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||
import { DataSyncService } from '../../services/data-sync.service';
|
||||
import { LucideAngularModule, LucideIconData, Folder, FileText, Pencil, Trash2, Plus, ChevronRight, GripVertical } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { LoreService } from '../../services/lore.service';
|
||||
import { TemplateService } from '../../services/template.service';
|
||||
@@ -12,6 +15,7 @@ import { PageTitleService } from '../../services/page-title.service';
|
||||
import { Lore, LoreNode } from '../../services/lore.model';
|
||||
import { Page } from '../../services/page.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
import { byOrder } from '../../shared/folder-grouping.util';
|
||||
import { resolveIcon } from '../lore-icons';
|
||||
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
@@ -25,7 +29,7 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-folder-view',
|
||||
imports: [LucideAngularModule, TranslatePipe],
|
||||
imports: [LucideAngularModule, TranslatePipe, CdkDropList, CdkDrag],
|
||||
templateUrl: './folder-view.component.html',
|
||||
styleUrls: ['./folder-view.component.scss']
|
||||
})
|
||||
@@ -36,6 +40,7 @@ export class FolderViewComponent implements OnInit, OnDestroy {
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Plus = Plus;
|
||||
readonly ChevronRight = ChevronRight;
|
||||
readonly GripVertical = GripVertical;
|
||||
|
||||
loreId = '';
|
||||
folderId = '';
|
||||
@@ -55,14 +60,17 @@ export class FolderViewComponent implements OnInit, OnDestroy {
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private translate: TranslateService
|
||||
private translate: TranslateService,
|
||||
private dataSync: DataSyncService,
|
||||
private destroyRef: DestroyRef
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.dataSync.onChange(this.destroyRef, () => this.load());
|
||||
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
|
||||
// Réagit aux changements de :folderId pour que la navigation d'un dossier
|
||||
// à un autre via la sidebar ne démonte/remonte pas le composant à blanc.
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
|
||||
const next = pm.get('folderId')!;
|
||||
if (next !== this.folderId) {
|
||||
this.folderId = next;
|
||||
@@ -82,8 +90,9 @@ export class FolderViewComponent implements OnInit, OnDestroy {
|
||||
this.lore = sidebar.lore;
|
||||
this.node = node;
|
||||
this.pageTitleService.set(node.name);
|
||||
this.subfolders = sidebar.nodes.filter(n => n.parentId === this.folderId);
|
||||
this.pages = sidebar.pages.filter(p => p.nodeId === this.folderId);
|
||||
// Tri par `order` — cohérent avec l'arbre (comparateur `byOrder` mutualisé).
|
||||
this.subfolders = sidebar.nodes.filter(n => n.parentId === this.folderId).sort(byOrder);
|
||||
this.pages = sidebar.pages.filter(p => p.nodeId === this.folderId).sort(byOrder);
|
||||
this.ancestors = this.buildAncestors(node, sidebar.nodes);
|
||||
});
|
||||
}
|
||||
@@ -114,6 +123,49 @@ export class FolderViewComponent implements OnInit, OnDestroy {
|
||||
return resolveIcon(this.node?.icon ?? null);
|
||||
}
|
||||
|
||||
// --- Glisser-déposer (réordonnancement + déplacement de pages) -----------
|
||||
|
||||
/** Ids des zones de dépôt « sous-dossier » (cibles connectées depuis la liste des pages). */
|
||||
get subfolderListIds(): string[] {
|
||||
return this.subfolders.map(s => 'sub-' + s.id);
|
||||
}
|
||||
|
||||
/** Un drag transporte-t-il une Page (vs un dossier) ? La Page a un `title`. */
|
||||
private isPageDrag(drag: CdkDrag): boolean {
|
||||
const d = drag.data as { title?: string } | null;
|
||||
return !!d && typeof d.title === 'string';
|
||||
}
|
||||
|
||||
/** Prédicat de dépôt : n'accepter que des pages (cibles dossier + grille pages). */
|
||||
acceptPages = (drag: CdkDrag): boolean => this.isPageDrag(drag);
|
||||
/** Prédicat de dépôt : n'accepter que des dossiers (grille des sous-dossiers). */
|
||||
acceptFolders = (drag: CdkDrag): boolean => !this.isPageDrag(drag);
|
||||
|
||||
/** Réordonne les pages du dossier courant. */
|
||||
dropPage(event: CdkDragDrop<Page[]>): void {
|
||||
if (event.previousContainer !== event.container) return; // déplacement géré par dropPageIntoFolder
|
||||
if (event.previousIndex === event.currentIndex) return;
|
||||
moveItemInArray(this.pages, event.previousIndex, event.currentIndex);
|
||||
this.dataSync.persist(this.pageService.reorder(this.folderId, this.pages.map(p => p.id!)), () => this.load());
|
||||
}
|
||||
|
||||
/** Réordonne les sous-dossiers du dossier courant. */
|
||||
dropSubfolder(event: CdkDragDrop<LoreNode[]>): void {
|
||||
if (event.previousContainer !== event.container) return;
|
||||
if (event.previousIndex === event.currentIndex) return;
|
||||
moveItemInArray(this.subfolders, event.previousIndex, event.currentIndex);
|
||||
this.dataSync.persist(this.loreService.reorderNodes(this.folderId, this.subfolders.map(s => s.id!)), () => this.load());
|
||||
}
|
||||
|
||||
/** Déplace une page (déposée sur la carte d'un sous-dossier) dans ce sous-dossier. */
|
||||
dropPageIntoFolder(target: LoreNode, event: CdkDragDrop<LoreNode>): void {
|
||||
const page = event.item.data as Page;
|
||||
if (!page || page.nodeId === target.id) return;
|
||||
// Succès → notify() → onChange → this.load() : le dossier courant se recharge
|
||||
// et la page déplacée en disparaît.
|
||||
this.dataSync.persist(this.pageService.reorder(target.id!, [page.id!]), () => this.load());
|
||||
}
|
||||
|
||||
navigateToSubfolder(id: string): void {
|
||||
this.router.navigate(['/lore', this.loreId, 'folders', id]);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { LayoutService } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { Lore, LoreNode } from '../../services/lore.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
import { byOrder } from '../../shared/folder-grouping.util';
|
||||
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
@Component({
|
||||
@@ -68,7 +69,7 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
|
||||
// Bug d'affichage corrigé : on ne liste ici que les dossiers racine
|
||||
// (les sous-dossiers apparaissent dans l'arbre de la sidebar quand on
|
||||
// ouvre leur parent). parentId null OU chaîne vide = racine.
|
||||
this.rootNodes = data.nodes.filter(n => !n.parentId);
|
||||
this.rootNodes = data.nodes.filter(n => !n.parentId).sort(byOrder); // même ordre que l'arbre
|
||||
this.layoutService.show(buildLoreSidebarConfig(data));
|
||||
this.pageTitleService.set(data.lore.name);
|
||||
// On sort du mode édition si on change de Lore en cours d'édition.
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PageService } from '../services/page.service';
|
||||
import { Lore, LoreNode } from '../services/lore.model';
|
||||
import { Template } from '../services/template.model';
|
||||
import { Page } from '../services/page.model';
|
||||
import { byOrder } from '../shared/folder-grouping.util';
|
||||
import {
|
||||
SecondarySidebarConfig, TreeItem, GlobalItem, BottomPanel
|
||||
} from '../services/layout.service';
|
||||
@@ -47,6 +48,8 @@ export function loadLoreSidebarData(
|
||||
export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarConfig {
|
||||
const { lore, allLores, nodes, templates, pages } = data;
|
||||
|
||||
// Tri par ORDRE manuel (glisser-déposer) — comparateur `byOrder` mutualisé.
|
||||
|
||||
// Regroupe les pages par nodeId et les sous-dossiers par parentId pour un accès O(1).
|
||||
const pagesByNode = new Map<string, Page[]>();
|
||||
for (const p of pages) {
|
||||
@@ -68,14 +71,15 @@ export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarC
|
||||
* hiérarchie) — "Nouveau sous-dossier" et "Nouvelle page".
|
||||
*/
|
||||
const buildFolderItem = (node: LoreNode): TreeItem => {
|
||||
const subFolders = childrenByParent.get(node.id!) ?? [];
|
||||
const nodePages = pagesByNode.get(node.id!) ?? [];
|
||||
const subFolders = (childrenByParent.get(node.id!) ?? []).slice().sort(byOrder);
|
||||
const nodePages = (pagesByNode.get(node.id!) ?? []).slice().sort(byOrder);
|
||||
const children: TreeItem[] = [
|
||||
...subFolders.map(buildFolderItem),
|
||||
...nodePages.map(p => ({
|
||||
id: `page-${p.id}`,
|
||||
label: p.title,
|
||||
route: `/lore/${lore.id}/pages/${p.id}`
|
||||
route: `/lore/${lore.id}/pages/${p.id}`,
|
||||
dragKind: 'page' as const, dragId: p.id
|
||||
}))
|
||||
];
|
||||
// IDs préfixés par type — chaque entité a sa propre séquence IDENTITY en base,
|
||||
@@ -88,6 +92,8 @@ export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarC
|
||||
route: `/lore/${lore.id}/folders/${node.id}`,
|
||||
meta: nodePages.length > 0 ? String(nodePages.length) : undefined,
|
||||
children,
|
||||
dragKind: 'folder', dragId: node.id,
|
||||
dropKinds: ['folder', 'page'], dropParentId: node.id,
|
||||
createActions: [
|
||||
{
|
||||
id: `create-folder-${node.id}`,
|
||||
@@ -106,7 +112,7 @@ export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarC
|
||||
};
|
||||
|
||||
// L'arbre démarre aux dossiers racine (parentId nul ou vide).
|
||||
const rootFolders = childrenByParent.get('__root__') ?? [];
|
||||
const rootFolders = (childrenByParent.get('__root__') ?? []).slice().sort(byOrder);
|
||||
const treeItems: TreeItem[] = rootFolders.map(buildFolderItem);
|
||||
|
||||
const globalItems: GlobalItem[] = allLores.map(l => ({
|
||||
@@ -116,7 +122,6 @@ export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarC
|
||||
const templatesPanel: BottomPanel = {
|
||||
id: 'templates',
|
||||
title: 'Templates',
|
||||
initiallyOpen: true,
|
||||
headerAction: {
|
||||
label: 'Nouveau template',
|
||||
route: `/lore/${lore.id}/templates/create`
|
||||
@@ -139,7 +144,11 @@ export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarC
|
||||
globalItems,
|
||||
globalBackLabel: 'Tous les lores',
|
||||
globalBackRoute: '/lore',
|
||||
bottomPanel: templatesPanel
|
||||
bottomPanel: templatesPanel,
|
||||
// DnD : les dossiers racine se réordonnent ; '' = parent racine (→ null côté service).
|
||||
rootDropKinds: ['folder'],
|
||||
rootDropParentId: '',
|
||||
reorderContext: { scope: 'lore', id: lore.id! }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Injectable } from '@angular/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { forkJoin, Subscription } from 'rxjs';
|
||||
import { CampaignService } from './campaign.service';
|
||||
import { CharacterService } from './character.service';
|
||||
import { NpcService } from './npc.service';
|
||||
import { RandomTableService } from './random-table.service';
|
||||
import { EnemyService } from './enemy.service';
|
||||
@@ -27,7 +26,6 @@ import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../campaigns/c
|
||||
export class CampaignSidebarService {
|
||||
constructor(
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
@@ -47,7 +45,6 @@ export class CampaignSidebarService {
|
||||
treeData: loadCampaignTreeData(
|
||||
this.campaignService,
|
||||
campaignId,
|
||||
this.characterService,
|
||||
this.npcService,
|
||||
this.randomTableService,
|
||||
this.enemyService
|
||||
|
||||
@@ -47,6 +47,11 @@ export class CampaignService {
|
||||
return this.http.get(`${this.apiUrl}/${id}/foundry-export`, { responseType: 'blob' });
|
||||
}
|
||||
|
||||
/** Génère et télécharge le livret PDF de la campagne. */
|
||||
exportPdf(id: string): Observable<Blob> {
|
||||
return this.http.get(`${this.apiUrl}/${id}/pdf-export`, { responseType: 'blob' });
|
||||
}
|
||||
|
||||
createCampaign(campaign: CampaignCreate): Observable<Campaign> {
|
||||
return this.http.post<Campaign>(this.apiUrl, campaign);
|
||||
}
|
||||
@@ -89,6 +94,11 @@ export class CampaignService {
|
||||
return this.http.get<ArcDeletionImpact>(`/api/arcs/${id}/deletion-impact`);
|
||||
}
|
||||
|
||||
/** Réordonne les arcs d'une campagne (glisser-déposer) : order = position. */
|
||||
reorderArcs(orderedIds: string[]): Observable<void> {
|
||||
return this.http.put<void>('/api/arcs/reorder', { orderedIds });
|
||||
}
|
||||
|
||||
// ========== CHAPTER ==========
|
||||
/**
|
||||
* Liste les chapitres d'un arc. Si {@code playthroughId} est fourni, le backend
|
||||
@@ -122,6 +132,11 @@ export class CampaignService {
|
||||
return this.http.get<ChapterDeletionImpact>(`/api/chapters/${id}/deletion-impact`);
|
||||
}
|
||||
|
||||
/** Réordonne (et déplace) les chapitres d'un arc : order = position, arcId = arc cible. */
|
||||
reorderChapters(arcId: string, orderedIds: string[]): Observable<void> {
|
||||
return this.http.put<void>('/api/chapters/reorder', { arcId, orderedIds });
|
||||
}
|
||||
|
||||
// ========== SCENE ==========
|
||||
getScenes(chapterId: string): Observable<Scene[]> {
|
||||
const params = new HttpParams().set('chapterId', chapterId);
|
||||
@@ -144,6 +159,11 @@ export class CampaignService {
|
||||
return this.http.delete<void>(`/api/scenes/${id}`);
|
||||
}
|
||||
|
||||
/** Réordonne (et déplace) les scènes d'un chapitre : order = position, chapterId = cible. */
|
||||
reorderScenes(chapterId: string, orderedIds: string[]): Observable<void> {
|
||||
return this.http.put<void>('/api/scenes/reorder', { chapterId, orderedIds });
|
||||
}
|
||||
|
||||
search(q: string): Observable<Campaign[]> {
|
||||
const params = new HttpParams().set('q', q);
|
||||
return this.http.get<Campaign[]>(`${this.apiUrl}/search`, { params });
|
||||
|
||||
38
web/src/app/services/data-sync.service.ts
Normal file
38
web/src/app/services/data-sync.service.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Injectable, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Bus d'événement minimal pour synchroniser les vues qui affichent les MÊMES données
|
||||
* (arbre de la sidebar ↔ vues en cartes). Après un réordonnancement/déplacement
|
||||
* (depuis n'importe quelle vue), on émet `changed$` ; chaque vue concernée se recharge
|
||||
* → tout reste cohérent SANS rafraîchir la page (F5).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DataSyncService {
|
||||
private readonly _changed = new Subject<void>();
|
||||
/** Émis quand une donnée ordonnable a changé (reorder/move). */
|
||||
readonly changed$ = this._changed.asObservable();
|
||||
|
||||
notify(): void {
|
||||
this._changed.next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Abonne `handler` aux changements de données, avec auto-désabonnement lié au
|
||||
* cycle de vie du composant (`destroyRef`). Évite de recopier le couple
|
||||
* `changed$.pipe(takeUntilDestroyed(...)).subscribe(...)` dans chaque vue.
|
||||
*/
|
||||
onChange(destroyRef: DestroyRef, handler: () => void): void {
|
||||
this.changed$.pipe(takeUntilDestroyed(destroyRef)).subscribe(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persiste un réordonnancement : au succès, notifie les autres vues
|
||||
* (`notify()`) ; en cas d'échec, exécute `rollback` (typiquement recharger pour
|
||||
* annuler le déplacement optimiste à l'écran).
|
||||
*/
|
||||
persist<T>(reorder$: Observable<T>, rollback: () => void): void {
|
||||
reorder$.subscribe({ next: () => this.notify(), error: () => rollback() });
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,11 @@ export class EnemyService {
|
||||
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
/** Réordonne (et reclasse) les ennemis d'un dossier : order = position, folder = cible. */
|
||||
reorder(folder: string | null, orderedIds: string[]): Observable<void> {
|
||||
return this.http.put<void>(`${this.apiUrl}/reorder`, { folder, orderedIds });
|
||||
}
|
||||
|
||||
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
|
||||
search(q: string): Observable<Enemy[]> {
|
||||
return this.http.get<Enemy[]>(`${this.apiUrl}/search`, { params: { q } });
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
/** Type d'entité déplaçable (réordonnancement par glisser-déposer dans l'arbre). */
|
||||
export type ReorderKind = 'arc' | 'chapter' | 'scene' | 'npc' | 'enemy' | 'table' | 'folder' | 'page';
|
||||
|
||||
/** Contexte servant à recharger la bonne sidebar après un réordonnancement. */
|
||||
export interface SidebarReorderContext {
|
||||
scope: 'campaign' | 'lore';
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface TreeItem {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -9,6 +18,14 @@ export interface TreeItem {
|
||||
isAction?: boolean; // style "action" (ex: "+ Nouveau chapitre")
|
||||
/** Clé d'icône optionnelle (ex: "users"). Résolue par le composant via `resolveIcon`. */
|
||||
iconKey?: string;
|
||||
/** Type d'entité déplaçable (active le drag sur ce nœud). Absent = non déplaçable. */
|
||||
dragKind?: ReorderKind;
|
||||
/** Id BRUT de l'entité (sans préfixe) — utilisé pour l'ordre persisté. */
|
||||
dragId?: string;
|
||||
/** Types acceptés en dépôt dans la liste des enfants de ce nœud (drop list). */
|
||||
dropKinds?: ReorderKind[];
|
||||
/** Parent transmis au réordonnancement (arcId, chapterId, nodeId, dossier…). */
|
||||
dropParentId?: string | null;
|
||||
/** Petit badge affiché à droite (ex: "3" pour compter les pages d'un dossier). */
|
||||
meta?: string;
|
||||
/**
|
||||
@@ -66,7 +83,6 @@ export interface BottomPanel {
|
||||
id: string; // identifiant pour mémoriser l'état ouvert/fermé
|
||||
title: string;
|
||||
items: BottomPanelItem[];
|
||||
initiallyOpen?: boolean;
|
||||
/** Action "+" inline dans le header — créer un item sans déplier le panneau. */
|
||||
headerAction?: { label: string; route: string };
|
||||
}
|
||||
@@ -87,6 +103,12 @@ export interface SecondarySidebarConfig {
|
||||
bottomPanel?: BottomPanel; // optionnel : présent côté Lore (Templates)
|
||||
/** @deprecated Remplacé par bottomPanel. Gardé pour compat des callers campagne. */
|
||||
footerLabel?: string;
|
||||
/** Types déplaçables acceptés à la RACINE de l'arbre (ex: arcs côté campagne). */
|
||||
rootDropKinds?: ReorderKind[];
|
||||
/** Parent transmis au reorder pour les éléments racine (campaignId, ou null = racine lore). */
|
||||
rootDropParentId?: string | null;
|
||||
/** Contexte de rechargement après un réordonnancement (présent = DnD activé). */
|
||||
reorderContext?: SidebarReorderContext;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface LoreNode {
|
||||
/** ID du dossier parent (null = racine). */
|
||||
parentId?: string | null;
|
||||
loreId: string;
|
||||
/** Position parmi les dossiers frères (glisser-déposer). */
|
||||
order?: number;
|
||||
/** Champs historiques non encore persistés côté backend — gardés pour compat de l'UI. */
|
||||
type?: string;
|
||||
description?: string;
|
||||
|
||||
@@ -117,6 +117,15 @@ export class LoreService {
|
||||
return this.http.delete<void>(`${this.nodesUrl}/${id}`).pipe(tap(() => this.invalidate()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Réordonne (et déplace) des dossiers : `order` = position dans `orderedIds`, et
|
||||
* chaque dossier reçoit `parentId` (null = racine). Anti-cycle géré côté backend.
|
||||
*/
|
||||
reorderNodes(parentId: string | null, orderedIds: string[]): Observable<void> {
|
||||
return this.http.put<void>(`${this.nodesUrl}/reorder`, { parentId, orderedIds })
|
||||
.pipe(tap(() => this.invalidate()));
|
||||
}
|
||||
|
||||
getLoreNodeDeletionImpact(id: string): Observable<LoreNodeDeletionImpact> {
|
||||
return this.http.get<LoreNodeDeletionImpact>(`${this.nodesUrl}/${id}/deletion-impact`);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,11 @@ export class NpcService {
|
||||
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
/** Réordonne (et reclasse) les PNJ d'un dossier : order = position, folder = dossier cible. */
|
||||
reorder(folder: string | null, orderedIds: string[]): Observable<void> {
|
||||
return this.http.put<void>(`${this.apiUrl}/reorder`, { folder, orderedIds });
|
||||
}
|
||||
|
||||
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
|
||||
search(q: string): Observable<Npc[]> {
|
||||
return this.http.get<Npc[]>(`${this.apiUrl}/search`, { params: { q } });
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface Page {
|
||||
nodeId: string;
|
||||
templateId?: string | null;
|
||||
title: string;
|
||||
/** Position de la page dans son dossier (glisser-déposer). */
|
||||
order?: number;
|
||||
values?: Record<string, string>;
|
||||
/**
|
||||
* Pour chaque champ IMAGE du template, la liste ordonnee des IDs d'images
|
||||
|
||||
@@ -59,6 +59,15 @@ export class PageService {
|
||||
return this.http.delete<void>(`${this.apiUrl}/${id}`).pipe(tap(() => this.invalidate()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Réordonne (et déplace) les pages d'un dossier : `order` = position dans
|
||||
* `orderedIds`, et chaque page reçoit `nodeId` (déplacement par glisser-déposer).
|
||||
*/
|
||||
reorder(nodeId: string, orderedIds: string[]): Observable<void> {
|
||||
return this.http.put<void>(`${this.apiUrl}/reorder`, { nodeId, orderedIds })
|
||||
.pipe(tap(() => this.invalidate()));
|
||||
}
|
||||
|
||||
search(q: string): Observable<Page[]> {
|
||||
const params = new HttpParams().set('q', q);
|
||||
return this.http.get<Page[]>(`${this.apiUrl}/search`, { params });
|
||||
|
||||
@@ -32,6 +32,11 @@ export class RandomTableService {
|
||||
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
/** Réordonne les tables aléatoires d'une campagne : order = position. */
|
||||
reorder(orderedIds: string[]): Observable<void> {
|
||||
return this.http.put<void>(`${this.apiUrl}/reorder`, { orderedIds });
|
||||
}
|
||||
|
||||
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
|
||||
search(q: string): Observable<RandomTable[]> {
|
||||
return this.http.get<RandomTable[]>(`${this.apiUrl}/search`, { params: { q } });
|
||||
|
||||
67
web/src/app/services/sidebar-reorder.service.ts
Normal file
67
web/src/app/services/sidebar-reorder.service.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { CampaignService } from './campaign.service';
|
||||
import { NpcService } from './npc.service';
|
||||
import { EnemyService } from './enemy.service';
|
||||
import { RandomTableService } from './random-table.service';
|
||||
import { PageService } from './page.service';
|
||||
import { LoreService } from './lore.service';
|
||||
import { TemplateService } from './template.service';
|
||||
import { LayoutService, ReorderKind, SidebarReorderContext } from './layout.service';
|
||||
import { CampaignSidebarService } from './campaign-sidebar.service';
|
||||
import { DataSyncService } from './data-sync.service';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore/lore-sidebar.helper';
|
||||
|
||||
/**
|
||||
* Persiste un réordonnancement déclenché par glisser-déposer DANS L'ARBRE de la
|
||||
* sidebar, puis recharge la sidebar depuis le backend (source de vérité).
|
||||
*
|
||||
* Le `kind` détermine l'endpoint (et le sens du `parentId` : arcId / chapterId /
|
||||
* nodeId / dossier…). On recharge systématiquement après la réponse — succès comme
|
||||
* erreur — pour resynchroniser l'affichage sur l'état persisté (zéro dérive).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SidebarReorderService {
|
||||
constructor(
|
||||
private campaignService: CampaignService,
|
||||
private npcService: NpcService,
|
||||
private enemyService: EnemyService,
|
||||
private randomTableService: RandomTableService,
|
||||
private pageService: PageService,
|
||||
private loreService: LoreService,
|
||||
private templateService: TemplateService,
|
||||
private layoutService: LayoutService,
|
||||
private campaignSidebar: CampaignSidebarService,
|
||||
private dataSync: DataSyncService
|
||||
) {}
|
||||
|
||||
reorder(context: SidebarReorderContext, kind: ReorderKind, parentId: string | null, orderedIds: string[]): void {
|
||||
this.persist(kind, parentId, orderedIds).subscribe({
|
||||
next: () => { this.reload(context); this.dataSync.notify(); },
|
||||
error: () => { this.reload(context); this.dataSync.notify(); }
|
||||
});
|
||||
}
|
||||
|
||||
private persist(kind: ReorderKind, parentId: string | null, orderedIds: string[]): Observable<void> {
|
||||
switch (kind) {
|
||||
case 'arc': return this.campaignService.reorderArcs(orderedIds);
|
||||
case 'chapter': return this.campaignService.reorderChapters(parentId ?? '', orderedIds);
|
||||
case 'scene': return this.campaignService.reorderScenes(parentId ?? '', orderedIds);
|
||||
case 'npc': return this.npcService.reorder(parentId || null, orderedIds);
|
||||
case 'enemy': return this.enemyService.reorder(parentId || null, orderedIds);
|
||||
case 'table': return this.randomTableService.reorder(orderedIds);
|
||||
case 'folder': return this.loreService.reorderNodes(parentId || null, orderedIds);
|
||||
case 'page': return this.pageService.reorder(parentId ?? '', orderedIds);
|
||||
default: return of(void 0);
|
||||
}
|
||||
}
|
||||
|
||||
private reload(context: SidebarReorderContext): void {
|
||||
if (context.scope === 'campaign') {
|
||||
this.campaignSidebar.show(context.id);
|
||||
} else {
|
||||
loadLoreSidebarData(context.id, this.loreService, this.templateService, this.pageService)
|
||||
.subscribe(data => this.layoutService.show(buildLoreSidebarConfig(data)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,8 +116,8 @@ export class SessionReferencePanelComponent implements OnChanges {
|
||||
private ensureTreeLoaded(): void {
|
||||
if (this.treeLoaded || this.loadingTree || !this.campaignId) return;
|
||||
this.loadingTree = true;
|
||||
loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, undefined, this.enemyService).pipe(
|
||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [], enemies: [] } as CampaignTreeData))
|
||||
loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, undefined, this.enemyService).pipe(
|
||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, npcs: [], randomTables: [], enemies: [] } as CampaignTreeData))
|
||||
).subscribe(data => {
|
||||
this.treeData = data;
|
||||
this.loadingTree = false;
|
||||
|
||||
46
web/src/app/shared/folder-grouping.util.ts
Normal file
46
web/src/app/shared/folder-grouping.util.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Regroupement par dossier + tri par ordre manuel, mutualisé entre toutes les
|
||||
* vues ordonnables (listes PNJ/ennemis, grille de la page campagne, sidebar).
|
||||
* Une seule définition du tri des dossiers évite que la sidebar et les cartes
|
||||
* divergent (cf. revue de propreté).
|
||||
*/
|
||||
|
||||
/** Comparateur d'ordre manuel (`order` croissant, défaut 0). */
|
||||
export const byOrder = (a: { order?: number }, b: { order?: number }): number =>
|
||||
(a.order ?? 0) - (b.order ?? 0);
|
||||
|
||||
/** Comparateur de noms de dossier : alphabétique, insensible casse/accents (comme la sidebar). */
|
||||
export const byFolderName = (a: string, b: string): number =>
|
||||
a.localeCompare(b, 'fr', { sensitivity: 'base' });
|
||||
|
||||
/** Un dossier (`folder`, `''` = non-classé) et ses éléments, déjà triés par ordre. */
|
||||
export interface FolderGroup<T> {
|
||||
folder: string;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Groupe `items` par dossier : dossiers triés alphabétiquement, éléments
|
||||
* non-classés (folder vide) regroupés en dernier. Chaque groupe est trié par
|
||||
* `order`. Renvoie une structure neutre (`{ folder, items }`) que chaque vue
|
||||
* adapte à son besoin (cartes, arbre…).
|
||||
*/
|
||||
export function groupByFolder<T extends { order?: number; folder?: string | null }>(items: T[]): FolderGroup<T>[] {
|
||||
const sorted = [...items].sort(byOrder);
|
||||
const byFolder = new Map<string, T[]>();
|
||||
const ungrouped: T[] = [];
|
||||
for (const it of sorted) {
|
||||
const f = (it.folder ?? '').trim();
|
||||
if (f) {
|
||||
if (!byFolder.has(f)) byFolder.set(f, []);
|
||||
byFolder.get(f)!.push(it);
|
||||
} else {
|
||||
ungrouped.push(it);
|
||||
}
|
||||
}
|
||||
const groups: FolderGroup<T>[] = [...byFolder.keys()]
|
||||
.sort(byFolderName)
|
||||
.map(folder => ({ folder, items: byFolder.get(folder)! }));
|
||||
if (ungrouped.length) groups.push({ folder: '', items: ungrouped });
|
||||
return groups;
|
||||
}
|
||||
@@ -33,75 +33,33 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<!-- cdkDropListGroup : connecte toutes les drop lists de l'arbre (déplacement d'un
|
||||
nœud d'une liste à l'autre). Chaque ligne est un <app-sidebar-tree-node> :
|
||||
comme c'est un VRAI composant déclaré statiquement dans la drop list, son
|
||||
cdkDrag résout sa drop list par DI (impossible via un template outlet-é). -->
|
||||
<div class="tree">
|
||||
@for (item of items; track item) {
|
||||
<ng-container *ngTemplateOutlet="treeNode; context: { $implicit: item, level: 0 }"></ng-container>
|
||||
}
|
||||
</div>
|
||||
<!-- Template récursif : un noeud d'arbre rend son bouton, puis ses enfants via ce même template -->
|
||||
<ng-template #treeNode let-item let-level="level">
|
||||
@if (level === 0 && item.sectionHeaderBefore) {
|
||||
<div class="tree-section-header">
|
||||
{{ item.sectionHeaderBefore }}
|
||||
</div>
|
||||
}
|
||||
<div class="tree-item" [style.padding-left.px]="level * 12">
|
||||
<div class="tree-row">
|
||||
@if (!item.isAction && isExpandable(item)) {
|
||||
<button
|
||||
type="button"
|
||||
class="chevron-btn"
|
||||
(click)="clickChevron($event, item)">
|
||||
<lucide-icon
|
||||
[img]="isExpanded(item.id) ? ChevronDown : ChevronRight"
|
||||
[size]="12">
|
||||
</lucide-icon>
|
||||
</button>
|
||||
<div class="tree-list" cdkDropList
|
||||
id="sidebar-root"
|
||||
[cdkDropListData]="rootDragData"
|
||||
[cdkDropListConnectedTo]="dropListIds"
|
||||
[cdkDropListDisabled]="!dndEnabled()"
|
||||
[cdkDropListEnterPredicate]="rootEnterPredicate"
|
||||
(cdkDropListDropped)="onDrop(null, $event)">
|
||||
@for (item of items; track item.id; let first = $first) {
|
||||
<!-- En-tête de section RENDU HORS du nœud (sinon il serait emporté par le
|
||||
drag du premier item). C'est un élément non-déplaçable de la drop list. -->
|
||||
@if (item.sectionHeaderBefore) {
|
||||
<div class="tree-section-header" [class.first]="first">{{ item.sectionHeaderBefore }}</div>
|
||||
}
|
||||
@if (item.isAction || !isExpandable(item)) {
|
||||
<span class="chevron-spacer"></span>
|
||||
@if (dndEnabled() && item.dragKind) {
|
||||
<app-sidebar-tree-node cdkDrag [cdkDragData]="item" cdkDragLockAxis="y"
|
||||
[item]="item" [level]="0"></app-sidebar-tree-node>
|
||||
} @else {
|
||||
<app-sidebar-tree-node [item]="item" [level]="0"></app-sidebar-tree-node>
|
||||
}
|
||||
<button type="button" class="tree-btn"
|
||||
[class.action]="item.isAction"
|
||||
[class.active]="isActive(item)"
|
||||
(click)="clickItem(item)">
|
||||
@if (iconFor(item); as icon) {
|
||||
<lucide-icon
|
||||
[img]="icon"
|
||||
[size]="14"
|
||||
class="item-icon">
|
||||
</lucide-icon>
|
||||
}
|
||||
{{ item.label }}
|
||||
@if (!item.isAction && item.meta) {
|
||||
<span class="tree-item-meta">{{ item.meta }}</span>
|
||||
}
|
||||
</button>
|
||||
<!-- Actions de creation contextuelles, revelees au survol de la ligne -->
|
||||
@if (item.createActions?.length) {
|
||||
<span class="node-actions">
|
||||
@for (a of item.createActions; track a) {
|
||||
<button
|
||||
type="button"
|
||||
class="node-action-btn"
|
||||
[title]="a.label"
|
||||
[attr.aria-label]="a.label"
|
||||
(click)="runCreateAction($event, a)">
|
||||
<lucide-icon [img]="iconForAction(a)" [size]="16"></lucide-icon>
|
||||
</button>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (isExpanded(item.id) && hasChildren(item)) {
|
||||
<div class="tree-children">
|
||||
@for (child of item.children; track child) {
|
||||
<ng-container *ngTemplateOutlet="treeNode; context: { $implicit: child, level: level + 1 }"></ng-container>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
<!-- Panneau bas (ex: Templates) ------------------------------------ -->
|
||||
@if (bottomPanel) {
|
||||
<section class="bottom-panel">
|
||||
|
||||
@@ -50,23 +50,6 @@
|
||||
&:hover { color: white; background: #1f2937; }
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
transition: color 0.2s;
|
||||
width: fit-content;
|
||||
|
||||
&:hover { color: white; }
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
@@ -132,153 +115,31 @@
|
||||
|
||||
.tree {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
overflow-y: auto;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
.tree-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
// NB : les styles d'une LIGNE d'arbre (tree-row, tree-btn, chevron, dossier, poignée…)
|
||||
// vivent désormais dans sidebar-tree-node.component.scss (composant récursif).
|
||||
|
||||
// En-tête de section — groupe visuellement les nœuds racines (ex: Personnages / Narration).
|
||||
// Un filet au-dessus crée la séparation ; pas de filet pour la première section
|
||||
// (le titre suffit) — on cible ça via :not(:first-child).
|
||||
// En-tête de section (NARRATION / PERSONNAGES / OUTILS) — rendu ici, hors des nœuds.
|
||||
.tree-section-header {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #9ca3af;
|
||||
padding: 0.6rem 0.5rem 0.3rem;
|
||||
}
|
||||
.tree > .tree-section-header:not(:first-child) {
|
||||
border-top: 1px solid #374151;
|
||||
margin-top: 0.35rem;
|
||||
padding-top: 0.6rem;
|
||||
padding: 0.6rem 0.5rem 0.3rem;
|
||||
|
||||
&.first { border-top: none; margin-top: 0; padding-top: 0.3rem; }
|
||||
}
|
||||
|
||||
.tree-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #d1d5db;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover { background: #1f2937; }
|
||||
&.action { color: #6b7280; font-style: italic; }
|
||||
&.action:hover { color: #a5b4fc; background: #1f2937; }
|
||||
|
||||
// Dossier / page / scène actuellement affichée : surligné avec un accent
|
||||
// violet et une barre gauche pour repérer instantanément où on se trouve,
|
||||
// utile quand plusieurs entrées partagent le même label.
|
||||
&.active {
|
||||
background: #1e1b4b;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
box-shadow: inset 3px 0 0 #6c63ff;
|
||||
}
|
||||
&.active:hover { background: #2a2558; }
|
||||
|
||||
.tree-item-meta {
|
||||
margin-left: auto;
|
||||
font-size: 0.72rem;
|
||||
color: #6b7280;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: #a5b4fc;
|
||||
flex-shrink: 0;
|
||||
margin-right: 0.1rem;
|
||||
}
|
||||
|
||||
.tree-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
// Hover-reveal : les boutons d'action de creation n'apparaissent qu'au
|
||||
// survol de la ligne. Pattern Notion/VS Code.
|
||||
&:hover .node-actions { opacity: 1; pointer-events: auto; }
|
||||
}
|
||||
|
||||
.node-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
// Superpose les boutons d'action sur le .tree-item-meta eventuel
|
||||
// (compteur de pages) : au hover on affiche les actions, au repos le meta.
|
||||
position: absolute;
|
||||
right: 0.25rem;
|
||||
background: linear-gradient(to right, transparent, #1f2937 30%);
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.node-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: rgba(55, 65, 81, 0.6);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: #d1d5db;
|
||||
padding: 0;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
|
||||
&:hover { background: #4338ca; color: #ffffff; }
|
||||
}
|
||||
|
||||
.chevron-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
padding: 0;
|
||||
|
||||
&:hover { background: #374151; color: white; }
|
||||
}
|
||||
|
||||
.chevron-spacer {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
|
||||
/* --- Bottom panel (ex: Templates) ------------------------------------- */
|
||||
.bottom-panel {
|
||||
border-top: 1px solid #1e1e3a;
|
||||
@@ -372,3 +233,7 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// NB : aperçu/placeholder = styles GLOBAUX (src/styles.scss), car l'aperçu CDK est
|
||||
// déplacé hors du composant. Ici, juste le retour visuel de la drop list RACINE.
|
||||
.tree-list.cdk-drop-list-receiving { background: rgba(138, 123, 200, 0.08); border-radius: 4px; }
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { Component, Input, Output, EventEmitter, HostListener, OnDestroy, ElementRef } from '@angular/core';
|
||||
import { Component, Input, Output, EventEmitter, HostListener, OnDestroy, ElementRef, forwardRef } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { CdkDropList, CdkDrag, CdkDragDrop, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
|
||||
import { LucideAngularModule, ChevronRight, ChevronDown, PanelLeftClose, PanelLeftOpen, Plus, FolderPlus, FilePlus, Home, LucideIconData } from 'lucide-angular';
|
||||
import { TreeItem, TreeCreateAction, SidebarAction, BottomPanel, BottomPanelItem, LayoutService } from '../../services/layout.service';
|
||||
import { TreeItem, TreeCreateAction, SidebarAction, BottomPanel, BottomPanelItem, LayoutService, ReorderKind, SidebarReorderContext } from '../../services/layout.service';
|
||||
import { SidebarReorderService } from '../../services/sidebar-reorder.service';
|
||||
import { SidebarTreeNodeComponent } from './sidebar-tree-node.component';
|
||||
import { resolveIcon } from '../../lore/lore-icons';
|
||||
|
||||
@Component({
|
||||
selector: 'app-secondary-sidebar',
|
||||
imports: [CommonModule, LucideAngularModule, TranslatePipe],
|
||||
imports: [CommonModule, LucideAngularModule, TranslatePipe, CdkDropList, CdkDrag, forwardRef(() => SidebarTreeNodeComponent)],
|
||||
templateUrl: './secondary-sidebar.component.html',
|
||||
styleUrls: ['./secondary-sidebar.component.scss']
|
||||
})
|
||||
@@ -18,6 +21,10 @@ export class SecondarySidebarComponent implements OnDestroy {
|
||||
@Input() titleRoute: string | null = null;
|
||||
@Input() createActions: SidebarAction[] = [];
|
||||
@Input() bottomPanel: BottomPanel | null = null;
|
||||
/** DnD : types déplaçables à la racine, parent racine, et contexte de rechargement. */
|
||||
@Input() rootDropKinds: ReorderKind[] | null = null;
|
||||
@Input() rootDropParentId: string | null = null;
|
||||
@Input() reorderContext: SidebarReorderContext | null = null;
|
||||
@Output() collapsedChange = new EventEmitter<boolean>();
|
||||
|
||||
/** true = ouvert (on affiche les items) ; false = replié (titre seul). */
|
||||
@@ -47,16 +54,35 @@ export class SecondarySidebarComponent implements OnDestroy {
|
||||
|
||||
private _items: TreeItem[] = [];
|
||||
|
||||
// Données de drag STABLES (références constantes entre détections de changement).
|
||||
// Indispensable : recalculer le tableau à chaque CD réinitialiserait le tri CDK en
|
||||
// plein drag (l'élément flotterait sans s'insérer). Reconstruites au set d'items.
|
||||
private _dragData = new Map<string, TreeItem[]>();
|
||||
private _predicates = new Map<string, (drag: CdkDrag) => boolean>();
|
||||
private _nodesById = new Map<string, TreeItem>();
|
||||
rootDragData: TreeItem[] = [];
|
||||
/** Ids de TOUTES les drop lists, pour les connecter explicitement (registre global
|
||||
* CDK → fonctionne à travers les composants, contrairement à cdkDropListGroup). */
|
||||
dropListIds: string[] = ['sidebar-root'];
|
||||
|
||||
@Input() set items(value: TreeItem[]) {
|
||||
this._items = value ?? [];
|
||||
this._buildDragMaps();
|
||||
this.autoExpandActiveAncestors();
|
||||
}
|
||||
get items(): TreeItem[] { return this._items; }
|
||||
|
||||
/** Prédicat racine (stable) : lit `rootDropKinds` au moment de l'appel. */
|
||||
rootEnterPredicate = (drag: CdkDrag): boolean => {
|
||||
const k = (drag.data as TreeItem | undefined)?.dragKind;
|
||||
return !!k && !!this.rootDropKinds && this.rootDropKinds.includes(k);
|
||||
};
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private layoutService: LayoutService,
|
||||
private elementRef: ElementRef<HTMLElement>
|
||||
private elementRef: ElementRef<HTMLElement>,
|
||||
private reorderService: SidebarReorderService
|
||||
) {
|
||||
try {
|
||||
const stored = localStorage.getItem(SecondarySidebarComponent.WIDTH_STORAGE_KEY);
|
||||
@@ -199,6 +225,109 @@ export class SecondarySidebarComponent implements OnDestroy {
|
||||
return !!item.children && item.children.length > 0;
|
||||
}
|
||||
|
||||
// --- Glisser-déposer (réordonnancement dans l'arbre) --------------------
|
||||
|
||||
/** DnD actif uniquement si un contexte de rechargement est fourni (campagne/lore). */
|
||||
dndEnabled(): boolean {
|
||||
return !!this.reorderContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruit, pour chaque nœud, son tableau d'enfants déplaçables et son prédicat
|
||||
* de dépôt — une seule fois (au set d'items) pour fournir des références STABLES.
|
||||
* Filtrer les non-déplaçables garde les indices CDK alignés sur la donnée.
|
||||
*/
|
||||
private _buildDragMaps(): void {
|
||||
this._dragData.clear();
|
||||
this._predicates.clear();
|
||||
this._nodesById.clear();
|
||||
this.dropListIds = ['sidebar-root'];
|
||||
this.rootDragData = this._items.filter(i => !!i.dragKind);
|
||||
const walk = (nodes: TreeItem[]): void => {
|
||||
for (const n of nodes) {
|
||||
this._nodesById.set(n.id, n);
|
||||
if (n.dropKinds) {
|
||||
this._predicates.set(n.id, this._makePredicate(n.dropKinds));
|
||||
this.dropListIds.push('lst-' + n.id);
|
||||
}
|
||||
if (n.children?.length) {
|
||||
this._dragData.set(n.id, n.children.filter(c => !!c.dragKind));
|
||||
walk(n.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(this._items);
|
||||
}
|
||||
|
||||
private _makePredicate(kinds: ReorderKind[] | null): (drag: CdkDrag) => boolean {
|
||||
return (drag: CdkDrag): boolean => {
|
||||
const k = (drag.data as TreeItem | undefined)?.dragKind;
|
||||
return !!k && !!kinds && kinds.includes(k);
|
||||
};
|
||||
}
|
||||
|
||||
/** Données (stables) de la drop list des enfants d'un nœud. */
|
||||
dragDataFor(item: TreeItem): TreeItem[] {
|
||||
return this._dragData.get(item.id) ?? [];
|
||||
}
|
||||
|
||||
/** Prédicat (stable) de la drop list des enfants d'un nœud. */
|
||||
enterPredicateFor(item: TreeItem): (drag: CdkDrag) => boolean {
|
||||
return this._predicates.get(item.id) ?? (() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dépôt d'un élément. Le POINT DE LÂCHER fait autorité (override fiable de la
|
||||
* détection de conteneur de CDK, qui est ambiguë avec des listes imbriquées) :
|
||||
* - lâché sur un AUTRE dossier (compatible) → déplacement DANS ce dossier ;
|
||||
* - sinon → réordonnancement dans la liste courante.
|
||||
* Puis on persiste et on recharge depuis le backend (source de vérité).
|
||||
*/
|
||||
onDrop(parent: TreeItem | null, event: CdkDragDrop<TreeItem[]>): void {
|
||||
if (!this.reorderContext) return;
|
||||
const dragged = event.item.data as TreeItem;
|
||||
const kind = dragged?.dragKind;
|
||||
if (!kind || !dragged.dragId) return;
|
||||
|
||||
// 1) Le curseur est-il sur un dossier compatible, DIFFÉRENT du dossier d'origine ?
|
||||
const folder = this.folderAtPoint(event.dropPoint, kind);
|
||||
const currentParent = parent ? (parent.dropParentId ?? null) : (this.rootDropParentId ?? null);
|
||||
if (folder && (folder.dropParentId ?? null) !== currentParent) {
|
||||
this.moveIntoFolder(dragged, kind, folder);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) Sinon : réordonnancement (même liste) ou déplacement géré par CDK (autre liste).
|
||||
const target = event.container.data;
|
||||
if (event.previousContainer === event.container) {
|
||||
if (event.previousIndex === event.currentIndex) return;
|
||||
moveItemInArray(target, event.previousIndex, event.currentIndex);
|
||||
} else {
|
||||
transferArrayItem(event.previousContainer.data, target, event.previousIndex, event.currentIndex);
|
||||
}
|
||||
const orderedIds = target.filter(i => i.dragKind === kind).map(i => i.dragId!).filter(Boolean);
|
||||
this.reorderService.reorder(this.reorderContext, kind, currentParent, orderedIds);
|
||||
}
|
||||
|
||||
/** Dossier compatible sous le point de lâcher (via le DOM réel), ou null. */
|
||||
private folderAtPoint(pt: { x: number; y: number } | undefined, kind: ReorderKind): TreeItem | null {
|
||||
if (!pt) return null;
|
||||
const el = document.elementFromPoint(pt.x, pt.y) as HTMLElement | null;
|
||||
const nodeEl = el?.closest('[data-drop-node]') as HTMLElement | null;
|
||||
const id = nodeEl?.getAttribute('data-drop-node');
|
||||
const node = id ? this._nodesById.get(id) : undefined;
|
||||
return node && node.dropKinds?.includes(kind) ? node : null;
|
||||
}
|
||||
|
||||
/** Déplace l'élément à la FIN d'un dossier cible (déplacement inter-dossiers). */
|
||||
private moveIntoFolder(dragged: TreeItem, kind: ReorderKind, folder: TreeItem): void {
|
||||
const existing = (this._dragData.get(folder.id) ?? [])
|
||||
.filter(i => i.dragKind === kind)
|
||||
.map(i => i.dragId!)
|
||||
.filter(id => id && id !== dragged.dragId);
|
||||
this.reorderService.reorder(this.reorderContext!, kind, folder.dropParentId ?? null, [...existing, dragged.dragId!]);
|
||||
}
|
||||
|
||||
/** True si le chevron doit s'afficher — seulement quand le noeud a de vrais enfants. */
|
||||
isExpandable(item: TreeItem): boolean {
|
||||
return this.hasChildren(item);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<div class="tree-row" [style.padding-left.px]="level * 12">
|
||||
@if (sidebar.dndEnabled() && item.dragKind) {
|
||||
<span class="drag-handle" cdkDragHandle [title]="'secondarySidebar.dragHandle' | translate">
|
||||
<lucide-icon [img]="GripVertical" [size]="12"></lucide-icon>
|
||||
</span>
|
||||
}
|
||||
@if (!item.isAction && sidebar.isExpandable(item)) {
|
||||
<button type="button" class="chevron-btn" (click)="sidebar.clickChevron($event, item)">
|
||||
<lucide-icon [img]="sidebar.isExpanded(item.id) ? ChevronDown : ChevronRight" [size]="12"></lucide-icon>
|
||||
</button>
|
||||
}
|
||||
@if (item.isAction || !sidebar.isExpandable(item)) {
|
||||
<span class="chevron-spacer"></span>
|
||||
}
|
||||
<button type="button" class="tree-btn"
|
||||
[class.action]="item.isAction"
|
||||
[class.active]="sidebar.isActive(item)"
|
||||
(click)="sidebar.clickItem(item)">
|
||||
@if (sidebar.iconFor(item); as icon) {
|
||||
<lucide-icon [img]="icon" [size]="14" class="item-icon"></lucide-icon>
|
||||
}
|
||||
{{ item.label }}
|
||||
@if (!item.isAction && item.meta) {
|
||||
<span class="tree-item-meta">{{ item.meta }}</span>
|
||||
}
|
||||
</button>
|
||||
@if (item.createActions?.length) {
|
||||
<span class="node-actions">
|
||||
@for (a of item.createActions; track a) {
|
||||
<button type="button" class="node-action-btn"
|
||||
[title]="a.label" [attr.aria-label]="a.label"
|
||||
(click)="sidebar.runCreateAction($event, a)">
|
||||
<lucide-icon [img]="sidebar.iconForAction(a)" [size]="16"></lucide-icon>
|
||||
</button>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (sidebar.isExpanded(item.id) && sidebar.hasChildren(item)) {
|
||||
@if (sidebar.dndEnabled() && item.dropKinds) {
|
||||
<div class="tree-children" cdkDropList
|
||||
[id]="'lst-' + item.id"
|
||||
[cdkDropListData]="sidebar.dragDataFor(item)"
|
||||
[cdkDropListConnectedTo]="sidebar.dropListIds"
|
||||
[cdkDropListEnterPredicate]="sidebar.enterPredicateFor(item)"
|
||||
(cdkDropListDropped)="sidebar.onDrop(item, $event)">
|
||||
@for (child of item.children; track child.id) {
|
||||
@if (sidebar.dndEnabled() && child.dragKind) {
|
||||
<app-sidebar-tree-node cdkDrag [cdkDragData]="child" cdkDragLockAxis="y"
|
||||
[item]="child" [level]="level + 1"></app-sidebar-tree-node>
|
||||
} @else {
|
||||
<app-sidebar-tree-node [item]="child" [level]="level + 1"></app-sidebar-tree-node>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="tree-children">
|
||||
@for (child of item.children; track child.id) {
|
||||
<app-sidebar-tree-node [item]="child" [level]="level + 1"></app-sidebar-tree-node>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Le nœud est l'élément déplaçable (cdkDrag) → il doit être un bloc dans la colonne.
|
||||
// (Les en-têtes de section sont rendus HORS du nœud, dans la sidebar, pour qu'ils ne
|
||||
// soient pas emportés quand on déplace le premier item d'une section.)
|
||||
:host { display: block; }
|
||||
|
||||
.tree-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
// Hover-reveal : les actions de création n'apparaissent qu'au survol (Notion/VS Code).
|
||||
&:hover .node-actions { opacity: 1; pointer-events: auto; }
|
||||
}
|
||||
|
||||
.tree-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #d1d5db;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover { background: #1f2937; }
|
||||
&.action { color: #6b7280; font-style: italic; }
|
||||
&.action:hover { color: #a5b4fc; background: #1f2937; }
|
||||
|
||||
&.active {
|
||||
background: #1e1b4b;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
box-shadow: inset 3px 0 0 #6c63ff;
|
||||
}
|
||||
&.active:hover { background: #2a2558; }
|
||||
|
||||
.tree-item-meta {
|
||||
margin-left: auto;
|
||||
font-size: 0.72rem;
|
||||
color: #6b7280;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: #a5b4fc;
|
||||
flex-shrink: 0;
|
||||
margin-right: 0.1rem;
|
||||
}
|
||||
|
||||
.node-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
position: absolute;
|
||||
right: 0.25rem;
|
||||
background: linear-gradient(to right, transparent, #1f2937 30%);
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.node-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: rgba(55, 65, 81, 0.6);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: #d1d5db;
|
||||
padding: 0;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
|
||||
&:hover { background: #4338ca; color: #ffffff; }
|
||||
}
|
||||
|
||||
.chevron-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
padding: 0;
|
||||
|
||||
&:hover { background: #374151; color: white; }
|
||||
}
|
||||
|
||||
.chevron-spacer {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
// Zone de dépôt qui reçoit un élément valide : léger fond pour guider l'œil.
|
||||
.tree-children.cdk-drop-list-receiving { background: rgba(138, 123, 200, 0.08); border-radius: 4px; }
|
||||
|
||||
// Poignée de glisser-déposer : faible au repos, visible au survol de la ligne.
|
||||
.drag-handle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
flex-shrink: 0;
|
||||
margin-right: 1px;
|
||||
color: #6b7280;
|
||||
opacity: 0.25;
|
||||
cursor: grab;
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
}
|
||||
.tree-row:hover .drag-handle { opacity: 0.9; color: #8a7bc8; }
|
||||
.drag-handle:active { cursor: grabbing; }
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Component, Input, HostBinding, forwardRef, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { CdkDropList, CdkDrag, CdkDragHandle } from '@angular/cdk/drag-drop';
|
||||
import { LucideAngularModule, ChevronRight, ChevronDown, GripVertical } from 'lucide-angular';
|
||||
import { TreeItem } from '../../services/layout.service';
|
||||
import { SecondarySidebarComponent } from './secondary-sidebar.component';
|
||||
|
||||
/**
|
||||
* Un nœud de l'arbre de la sidebar (récursif).
|
||||
*
|
||||
* Pourquoi un COMPOSANT récursif et pas un `*ngTemplateOutlet` ? Parce que `cdkDrag`
|
||||
* résout sa drop list par INJECTION au lieu de DÉCLARATION du template : un template
|
||||
* outlet-é se résout au site de DÉCLARATION (hors des drop lists) → le drag n'a aucun
|
||||
* conteneur → aucun réordonnancement (l'élément flotte). En rendant chaque enfant via
|
||||
* `<app-sidebar-tree-node cdkDrag>` STATIQUEMENT dans la drop list du parent, le DI
|
||||
* trouve la bonne liste et le tri/insertion fonctionne.
|
||||
*
|
||||
* Toute la logique (clic, expand, icônes, DnD) est déléguée au composant sidebar parent
|
||||
* (injecté) pour éviter la duplication.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-sidebar-tree-node',
|
||||
imports: [
|
||||
CommonModule, LucideAngularModule, TranslatePipe,
|
||||
CdkDropList, CdkDrag, CdkDragHandle,
|
||||
forwardRef(() => SidebarTreeNodeComponent)
|
||||
],
|
||||
templateUrl: './sidebar-tree-node.component.html',
|
||||
styleUrls: ['./sidebar-tree-node.component.scss']
|
||||
})
|
||||
export class SidebarTreeNodeComponent {
|
||||
@Input({ required: true }) item!: TreeItem;
|
||||
@Input() level = 0;
|
||||
|
||||
/** Marque l'hôte comme dossier (cible de dépôt détectée par position de lâcher).
|
||||
* Posé sur l'HÔTE = ancêtre du header ET des enfants → un drop n'importe où sur le
|
||||
* dossier le résout correctement. */
|
||||
@HostBinding('attr.data-drop-node') get dropNodeAttr(): string | null {
|
||||
return this.item?.dropKinds ? this.item.id : null;
|
||||
}
|
||||
|
||||
readonly ChevronRight = ChevronRight;
|
||||
readonly ChevronDown = ChevronDown;
|
||||
readonly GripVertical = GripVertical;
|
||||
|
||||
/** Sidebar parente (toujours un ancêtre DI) : on lui délègue toute la logique. */
|
||||
readonly sidebar = inject(SecondarySidebarComponent);
|
||||
}
|
||||
@@ -260,7 +260,8 @@
|
||||
},
|
||||
"secondarySidebar": {
|
||||
"backToCampaignHome": "Back to campaign home",
|
||||
"resizeHandle": "Drag to resize"
|
||||
"resizeHandle": "Drag to resize",
|
||||
"dragHandle": "Drag to reorder"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"ariaLabel": "Breadcrumb"
|
||||
@@ -592,6 +593,8 @@
|
||||
"defaultPlaythroughName": "New playthrough {{n}}",
|
||||
"foundryExport": "Export to Foundry",
|
||||
"foundryExporting": "Exporting…",
|
||||
"pdfExport": "Export to PDF",
|
||||
"pdfExporting": "Generating…",
|
||||
"gameSystemChange": {
|
||||
"title": "Change the game system?",
|
||||
"message": "You are about to change the game system of this campaign. This also changes the template for PC and NPC sheets.",
|
||||
@@ -1496,6 +1499,7 @@
|
||||
},
|
||||
"campaignTree": {
|
||||
"npcs": "NPCs",
|
||||
"unclassified": "Unfiled",
|
||||
"newNpc": "New NPC",
|
||||
"newScene": "New scene",
|
||||
"newQuest": "New quest",
|
||||
|
||||
@@ -260,7 +260,8 @@
|
||||
},
|
||||
"secondarySidebar": {
|
||||
"backToCampaignHome": "Retour à l'accueil de la campagne",
|
||||
"resizeHandle": "Glissez pour redimensionner"
|
||||
"resizeHandle": "Glissez pour redimensionner",
|
||||
"dragHandle": "Glissez pour réordonner"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"ariaLabel": "Fil d'Ariane"
|
||||
@@ -592,6 +593,8 @@
|
||||
"defaultPlaythroughName": "Nouvelle partie {{n}}",
|
||||
"foundryExport": "Exporter pour Foundry",
|
||||
"foundryExporting": "Export…",
|
||||
"pdfExport": "Exporter en PDF",
|
||||
"pdfExporting": "Génération…",
|
||||
"gameSystemChange": {
|
||||
"title": "Changer le système de jeu ?",
|
||||
"message": "Vous êtes sur le point de changer le système de jeu de cette campagne. Cela change également le template des fiches de PJ et PNJ.",
|
||||
@@ -1496,6 +1499,7 @@
|
||||
},
|
||||
"campaignTree": {
|
||||
"npcs": "PNJ",
|
||||
"unclassified": "Sans dossier",
|
||||
"newNpc": "Nouveau PNJ",
|
||||
"newScene": "Nouvelle scène",
|
||||
"newQuest": "Nouvelle quête",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"campaignTree": {
|
||||
"npcs": "NPCs",
|
||||
"unclassified": "Unfiled",
|
||||
"newNpc": "New NPC",
|
||||
"newScene": "New scene",
|
||||
"newQuest": "New quest",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"campaignTree": {
|
||||
"npcs": "PNJ",
|
||||
"unclassified": "Sans dossier",
|
||||
"newNpc": "Nouveau PNJ",
|
||||
"newScene": "Nouvelle scène",
|
||||
"newQuest": "Nouvelle quête",
|
||||
|
||||
@@ -60,6 +60,8 @@
|
||||
"defaultPlaythroughName": "New playthrough {{n}}",
|
||||
"foundryExport": "Export to Foundry",
|
||||
"foundryExporting": "Exporting…",
|
||||
"pdfExport": "Export to PDF",
|
||||
"pdfExporting": "Generating…",
|
||||
"gameSystemChange": {
|
||||
"title": "Change the game system?",
|
||||
"message": "You are about to change the game system of this campaign. This also changes the template for PC and NPC sheets.",
|
||||
|
||||
@@ -60,6 +60,8 @@
|
||||
"defaultPlaythroughName": "Nouvelle partie {{n}}",
|
||||
"foundryExport": "Exporter pour Foundry",
|
||||
"foundryExporting": "Export…",
|
||||
"pdfExport": "Exporter en PDF",
|
||||
"pdfExporting": "Génération…",
|
||||
"gameSystemChange": {
|
||||
"title": "Changer le système de jeu ?",
|
||||
"message": "Vous êtes sur le point de changer le système de jeu de cette campagne. Cela change également le template des fiches de PJ et PNJ.",
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
},
|
||||
"secondarySidebar": {
|
||||
"backToCampaignHome": "Back to campaign home",
|
||||
"resizeHandle": "Drag to resize"
|
||||
"resizeHandle": "Drag to resize",
|
||||
"dragHandle": "Drag to reorder"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"ariaLabel": "Breadcrumb"
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
},
|
||||
"secondarySidebar": {
|
||||
"backToCampaignHome": "Retour à l'accueil de la campagne",
|
||||
"resizeHandle": "Glissez pour redimensionner"
|
||||
"resizeHandle": "Glissez pour redimensionner",
|
||||
"dragHandle": "Glissez pour réordonner"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"ariaLabel": "Fil d'Ariane"
|
||||
|
||||
@@ -19,3 +19,48 @@ body {
|
||||
background: #0a0a14;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* ===== Glisser-déposer (Angular CDK) — retours visuels GLOBAUX =====
|
||||
Globaux car l'aperçu (cdk-drag-preview) est déplacé dans <body> : les styles
|
||||
encapsulés des composants ne s'y appliquent pas. */
|
||||
|
||||
/* Aperçu qui suit le curseur : ligne compacte et opaque (jamais transparente). */
|
||||
.cdk-drag-preview {
|
||||
box-sizing: border-box;
|
||||
border-radius: 6px;
|
||||
background: #1b1b2b;
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.55);
|
||||
color: #e0e0e0;
|
||||
font-size: 0.9rem;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
/* Ne pas traîner tout un sous-arbre déplié quand on déplace un dossier/arc/quête. */
|
||||
.cdk-drag-preview .tree-children,
|
||||
.cdk-drag-preview .node-actions { display: none; }
|
||||
|
||||
/* Emplacement cible : on GARDE la hauteur de la ligne (visibility, pas display) pour
|
||||
qu'un vrai trou s'ouvre, et on affiche une barre d'insertion en pointillés. */
|
||||
.cdk-drag-placeholder {
|
||||
opacity: 1;
|
||||
background: rgba(138, 123, 200, 0.18);
|
||||
border: 1px dashed #8a7bc8;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* On masque le contenu (visibility = on garde la hauteur d'UNE ligne) mais on REPLIE
|
||||
le sous-arbre (display:none) pour que le trou d'insertion fasse une seule ligne. */
|
||||
.cdk-drag-placeholder > * { visibility: hidden; }
|
||||
.cdk-drag-placeholder .tree-children { display: none; }
|
||||
|
||||
/* Les voisins glissent pour ouvrir/refermer l'espace pendant le déplacement. */
|
||||
.cdk-drag-animating { transition: transform 180ms cubic-bezier(0, 0, 0.2, 1); }
|
||||
.cdk-drop-list-dragging .cdk-drag:not(.cdk-drag-placeholder) {
|
||||
transition: transform 180ms cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Curseurs de préhension (le drag part de la poignée). */
|
||||
[cdkDrag] { cursor: default; }
|
||||
.cdk-drag-handle, [cdkDragHandle] { cursor: grab; }
|
||||
.cdk-drag-handle:active, [cdkDragHandle]:active { cursor: grabbing; }
|
||||
/* Surbrillance d'une zone de dépôt qui reçoit un élément (ex: page sur dossier). */
|
||||
.cdk-drop-list-receiving { outline: 2px dashed #6c63ff; outline-offset: 2px; }
|
||||
|
||||
Reference in New Issue
Block a user