Correction bug suppression complète coté lore (et suppression dans tout ce qui est campagne de la partie lore liée).

Améliorations ux :
- Bandeau en haut qui reste accessible lors de la création d'un élément (chapitre, page, scène etc...)
- Mise en place d'un surlignage pour voir su quel élément on est positionné
This commit is contained in:
2026-04-23 14:06:50 +02:00
parent 96bc5de942
commit 8efdf5d0e0
33 changed files with 786 additions and 71 deletions

View File

@@ -0,0 +1,85 @@
<div class="folder-view" *ngIf="node">
<!-- Fil d'Ariane : Lore → ancêtres → dossier courant -->
<nav class="breadcrumb" aria-label="Fil d'Ariane">
<button type="button" class="crumb" (click)="navigateToLoreRoot()" *ngIf="lore">
{{ lore.name }}
</button>
<ng-container *ngFor="let ancestor of ancestors">
<lucide-icon [img]="ChevronRight" [size]="12" class="crumb-sep"></lucide-icon>
<button type="button" class="crumb" (click)="navigateToSubfolder(ancestor.id!)">
{{ ancestor.name }}
</button>
</ng-container>
<lucide-icon [img]="ChevronRight" [size]="12" class="crumb-sep"></lucide-icon>
<span class="crumb current">{{ node.name }}</span>
</nav>
<!-- Header : icône + nom + actions -->
<div class="detail-header">
<div class="header-texts">
<h1>
<lucide-icon [img]="folderIcon" [size]="24" class="title-icon"></lucide-icon>
{{ node.name }}
</h1>
<p class="description">
{{ subfolders.length }} sous-dossier(s) · {{ pages.length }} page(s)
</p>
</div>
<div class="header-actions">
<button type="button" class="btn-secondary" (click)="navigateToEdit()" title="Modifier le dossier">
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
Modifier
</button>
<button type="button" class="btn-danger" (click)="delete()" title="Supprimer le dossier et tout son contenu">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</div>
<!-- Sous-dossiers -->
<section class="detail-section">
<div class="section-header">
<h2>Sous-dossiers</h2>
<button class="btn-add" (click)="navigateToCreateSubfolder()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouveau sous-dossier
</button>
</div>
<div class="items-grid" *ngIf="subfolders.length > 0">
<div class="node-card" *ngFor="let sub of subfolders" (click)="navigateToSubfolder(sub.id!)">
<lucide-icon [img]="Folder" [size]="24" class="node-icon"></lucide-icon>
<span class="node-name">{{ sub.name }}</span>
</div>
</div>
<div class="empty-state" *ngIf="subfolders.length === 0">
<p>Aucun sous-dossier.</p>
</div>
</section>
<!-- Pages -->
<section class="detail-section">
<div class="section-header">
<h2>Pages</h2>
<button class="btn-add" (click)="navigateToCreatePage()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouvelle page
</button>
</div>
<div class="items-grid" *ngIf="pages.length > 0">
<div class="node-card" *ngFor="let page of pages" (click)="navigateToPage(page.id!)">
<lucide-icon [img]="FileText" [size]="24" class="node-icon"></lucide-icon>
<span class="node-name">{{ page.title }}</span>
</div>
</div>
<div class="empty-state" *ngIf="pages.length === 0">
<p>Aucune page dans ce dossier.</p>
</div>
</section>
</div>

View File

@@ -0,0 +1,154 @@
.folder-view {
padding: 2.5rem 2rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.breadcrumb {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.35rem;
font-size: 0.85rem;
color: #6b7280;
.crumb {
background: transparent;
border: none;
color: #9ca3af;
padding: 0.15rem 0.35rem;
border-radius: 4px;
cursor: pointer;
font-size: inherit;
transition: color 0.15s, background 0.15s;
&:hover { color: #c7d2fe; background: #1f2937; }
&.current {
color: #e5e7eb;
font-weight: 500;
cursor: default;
}
&.current:hover { background: transparent; }
}
.crumb-sep { color: #4b5563; flex-shrink: 0; }
}
.detail-header {
// Sticky pour que Modifier/Supprimer restent accessibles même en scrollant
// une longue liste de sous-dossiers/pages.
position: sticky;
top: 0;
z-index: 10;
background: #0a0a14;
padding: 1rem 0;
border-bottom: 1px solid #1f2937;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1.5rem;
.header-texts { flex: 1; min-width: 0; }
h1 {
display: inline-flex;
align-items: center;
gap: 0.6rem;
font-size: 1.75rem;
font-weight: 700;
color: white;
margin-bottom: 0.5rem;
.title-icon { color: #6c63ff; }
}
.description {
color: #6b7280;
font-size: 0.95rem;
}
.header-actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
}
.btn-secondary, .btn-danger {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
border: none;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.btn-secondary { background: #1f2937; color: #d1d5db; &:hover { background: #374151; } }
.btn-danger { background: #3a1e1e; color: #f87171; &:hover { background: #5a2e2e; } }
.detail-section {
background: #0d1117;
border: 1px solid #1f2937;
border-radius: 12px;
padding: 1.5rem 1.75rem;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
h2 { color: #d1d5db; font-size: 1rem; font-weight: 600; }
}
.btn-add {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
background: #6c63ff;
color: white;
border: none;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
&:hover { background: #5b52e0; }
}
.items-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 1rem;
}
.node-card {
background: #111827;
border: 1px solid #1f2937;
border-radius: 10px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
cursor: pointer;
transition: border-color 0.2s, transform 0.2s;
&:hover { border-color: #6c63ff; transform: translateY(-2px); }
.node-icon { color: #6c63ff; }
.node-name { color: white; font-size: 0.9rem; font-weight: 600; }
}
.empty-state {
color: #6b7280;
font-size: 0.9rem;
padding: 1rem 0.5rem;
}

View File

@@ -0,0 +1,179 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule, LucideIconData, Folder, FileText, Pencil, Trash2, Plus, ChevronRight } from 'lucide-angular';
import { LoreService } from '../../services/lore.service';
import { TemplateService } from '../../services/template.service';
import { PageService } from '../../services/page.service';
import { LayoutService } from '../../services/layout.service';
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 { resolveIcon } from '../lore-icons';
/**
* Vue "détail" d'un dossier : affiche son contenu (sous-dossiers + pages) et
* expose les actions Modifier / Supprimer dans le header.
*
* L'édition du nom/icône/parent se fait dans l'écran séparé folder-edit
* (/folders/:folderId/edit). La suppression avec cascade déclenche le même
* dialogue d'impact que les autres écrans.
*/
@Component({
selector: 'app-folder-view',
standalone: true,
imports: [CommonModule, LucideAngularModule],
templateUrl: './folder-view.component.html',
styleUrls: ['./folder-view.component.scss']
})
export class FolderViewComponent implements OnInit, OnDestroy {
readonly Folder = Folder;
readonly FileText = FileText;
readonly Pencil = Pencil;
readonly Trash2 = Trash2;
readonly Plus = Plus;
readonly ChevronRight = ChevronRight;
loreId = '';
folderId = '';
lore: Lore | null = null;
node: LoreNode | null = null;
subfolders: LoreNode[] = [];
pages: Page[] = [];
/** Chaîne des dossiers ancêtres (du plus proche du racine vers le parent direct). */
ancestors: LoreNode[] = [];
constructor(
private route: ActivatedRoute,
private router: Router,
private loreService: LoreService,
private templateService: TemplateService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
) {}
ngOnInit(): void {
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 => {
const next = pm.get('folderId')!;
if (next !== this.folderId) {
this.folderId = next;
this.load();
}
});
this.folderId = this.route.snapshot.paramMap.get('folderId')!;
this.load();
}
private load(): void {
forkJoin({
sidebar: loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService),
node: this.loreService.getLoreNodeById(this.folderId)
}).subscribe(({ sidebar, node }) => {
this.layoutService.show(buildLoreSidebarConfig(sidebar));
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);
this.ancestors = this.buildAncestors(node, sidebar.nodes);
});
}
/**
* Remonte la chaîne parentId → parent en partant du dossier courant,
* sans s'inclure soi-même. Ordre : racine → parent direct.
* Garde-fou sur la longueur au cas où une boucle existerait en BDD
* (ne devrait pas, mais ceinture+bretelles).
*/
private buildAncestors(current: LoreNode, allNodes: LoreNode[]): LoreNode[] {
const byId = new Map(allNodes.map(n => [n.id!, n]));
const chain: LoreNode[] = [];
const seen = new Set<string>();
let parentId = current.parentId ?? null;
while (parentId && !seen.has(parentId) && chain.length < 32) {
const parent = byId.get(parentId);
if (!parent) break;
chain.push(parent);
seen.add(parent.id!);
parentId = parent.parentId ?? null;
}
return chain.reverse();
}
/** Icône du dossier courant, résolue depuis la clé lucide stockée sur le node. */
get folderIcon(): LucideIconData {
return resolveIcon(this.node?.icon ?? null);
}
navigateToSubfolder(id: string): void {
this.router.navigate(['/lore', this.loreId, 'folders', id]);
}
navigateToLoreRoot(): void {
this.router.navigate(['/lore', this.loreId]);
}
navigateToPage(id: string): void {
this.router.navigate(['/lore', this.loreId, 'pages', id]);
}
navigateToCreateSubfolder(): void {
this.router.navigate(['/lore', this.loreId, 'folders', this.folderId, 'create']);
}
navigateToCreatePage(): void {
this.router.navigate(['/lore', this.loreId, 'nodes', this.folderId, 'pages', 'create']);
}
navigateToEdit(): void {
this.router.navigate(['/lore', this.loreId, 'folders', this.folderId, 'edit']);
}
/**
* Suppression en cascade avec dialogue d'impact. On délègue au backend (transaction
* atomique), et au retour on remonte soit au dossier parent soit au Lore racine.
*/
delete(): void {
if (!this.node) return;
const node = this.node;
this.loreService.getLoreNodeDeletionImpact(this.folderId).subscribe({
next: impact => {
const parts: string[] = [];
if (impact.folders > 0) parts.push(`${impact.folders} sous-dossier${impact.folders > 1 ? 's' : ''}`);
if (impact.pages > 0) parts.push(`${impact.pages} page${impact.pages > 1 ? 's' : ''}`);
const lines = [`Supprimer le dossier "${node.name}" ?`];
if (parts.length) {
lines.push('');
lines.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
}
lines.push('');
lines.push('Cette action est irréversible.');
if (!confirm(lines.join('\n'))) return;
this.loreService.deleteLoreNode(this.folderId).subscribe({
next: () => {
// Remonte au dossier parent si présent, sinon au Lore.
if (node.parentId) {
this.router.navigate(['/lore', this.loreId, 'folders', node.parentId]);
} else {
this.router.navigate(['/lore', this.loreId]);
}
},
error: () => console.error('Erreur lors de la suppression du dossier')
});
},
error: () => console.error('Impossible de récupérer les dépendances du dossier')
});
}
ngOnDestroy(): void {
this.layoutService.hide();
}
}

View File

@@ -15,11 +15,19 @@
}
.detail-header {
// Sticky : les actions Modifier/Supprimer du Lore restent accessibles
// quand on scrolle la grille de dossiers.
position: sticky;
top: 0;
z-index: 10;
background: #0a0a14;
padding: 1rem 0;
border-bottom: 1px solid #1f2937;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1.5rem;
margin-bottom: 2.5rem;
margin-bottom: 1.5rem;
.header-texts { flex: 1; min-width: 0; }

View File

@@ -77,7 +77,7 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
}
navigateToFolder(nodeId: string): void {
this.router.navigate(['/lore', this.lore!.id, 'folders', nodeId, 'edit']);
this.router.navigate(['/lore', this.lore!.id, 'folders', nodeId]);
}
// ─────────────── Édition / suppression du Lore ───────────────

View File

@@ -9,13 +9,6 @@
</div>
<div class="header-actions">
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
<button
type="button"
class="btn-danger"
title="Supprimer le dossier et tout son contenu"
(click)="delete()">
Supprimer
</button>
<button
type="submit"
class="btn-primary"

View File

@@ -129,45 +129,15 @@ export class LoreNodeEditComponent implements OnInit, OnDestroy {
parentId: raw.parentId && raw.parentId !== '' ? raw.parentId : null
};
this.loreService.updateLoreNode(this.folderId, updated).subscribe({
next: () => this.router.navigate(['/lore', this.loreId]),
next: () => this.router.navigate(['/lore', this.loreId, 'folders', this.folderId]),
error: () => console.error('Erreur lors de la sauvegarde du dossier')
});
}
/**
* Suppression en cascade : on va chercher le compte exact de sous-dossiers et
* de pages (qui tombent avec le dossier), on l'annonce dans la confirmation,
* puis on délègue au backend — l'atomicité est garantie côté transaction.
*/
delete(): void {
if (!this.node) return;
const node = this.node;
this.loreService.getLoreNodeDeletionImpact(this.folderId).subscribe({
next: impact => {
const parts: string[] = [];
if (impact.folders > 0) parts.push(`${impact.folders} sous-dossier${impact.folders > 1 ? 's' : ''}`);
if (impact.pages > 0) parts.push(`${impact.pages} page${impact.pages > 1 ? 's' : ''}`);
const lines = [`Supprimer le dossier "${node.name}" ?`];
if (parts.length) {
lines.push('');
lines.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
}
lines.push('');
lines.push('Cette action est irréversible.');
if (!confirm(lines.join('\n'))) return;
this.loreService.deleteLoreNode(this.folderId).subscribe({
next: () => this.router.navigate(['/lore', this.loreId]),
error: () => console.error('Erreur lors de la suppression du dossier')
});
},
error: () => console.error('Impossible de récupérer les dépendances du dossier')
});
}
cancel(): void {
this.router.navigate(['/lore', this.loreId]);
// Retour vers la vue détail du dossier plutôt que la racine du Lore :
// l'édition est un sous-écran du détail.
this.router.navigate(['/lore', this.loreId, 'folders', this.folderId]);
}
/** Retourne l'icône lucide à afficher dans l'aperçu du bouton "Sauvegarder". */

View File

@@ -85,7 +85,7 @@ export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarC
id: `folder-${node.id}`,
label: node.name,
iconKey: node.icon ?? undefined,
route: `/lore/${lore.id}/folders/${node.id}/edit`,
route: `/lore/${lore.id}/folders/${node.id}`,
meta: nodePages.length > 0 ? String(nodePages.length) : undefined,
children,
createActions: [

View File

@@ -147,7 +147,7 @@ export class PageEditComponent implements OnInit, OnDestroy {
for (const node of folderChain) {
items.push({
label: node.name,
route: ['/lore', this.loreId, 'folders', node.id, 'edit']
route: ['/lore', this.loreId, 'folders', node.id]
});
}

View File

@@ -12,6 +12,10 @@
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
Modifier
</button>
<button type="button" class="btn-danger" (click)="deletePage()" title="Supprimer la page">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</header>

View File

@@ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule, Pencil } from 'lucide-angular';
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
import { LoreService } from '../../services/lore.service';
import { TemplateService } from '../../services/template.service';
import { PageService } from '../../services/page.service';
@@ -34,6 +34,7 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
})
export class PageViewComponent implements OnInit, OnDestroy {
readonly Pencil = Pencil;
readonly Trash2 = Trash2;
loreId = '';
pageId = '';
@@ -96,7 +97,7 @@ export class PageViewComponent implements OnInit, OnDestroy {
: undefined;
}
for (const node of folderChain) {
items.push({ label: node.name, route: ['/lore', this.loreId, 'folders', node.id, 'edit'] });
items.push({ label: node.name, route: ['/lore', this.loreId, 'folders', node.id] });
}
items.push({ label: this.page.title });
return items;
@@ -121,6 +122,26 @@ export class PageViewComponent implements OnInit, OnDestroy {
this.router.navigate(['/lore', this.loreId, 'pages', this.pageId, 'edit']);
}
/**
* Suppression simple : pas d'enfants. On remonte au dossier parent
* si on peut, sinon à la racine du Lore.
*/
deletePage(): void {
if (!this.page) return;
const page = this.page;
if (!confirm(`Supprimer la page "${page.title}" ?\n\nCette action est irréversible.`)) return;
this.pageService.delete(page.id!).subscribe({
next: () => {
if (page.nodeId) {
this.router.navigate(['/lore', this.loreId, 'folders', page.nodeId]);
} else {
this.router.navigate(['/lore', this.loreId]);
}
},
error: () => console.error('Erreur lors de la suppression de la page')
});
}
ngOnDestroy(): void {
this.layoutService.hide();
}