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

@@ -1,9 +1,13 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@@ -17,11 +21,20 @@ import java.util.Optional;
public class ArcService {
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
public ArcService(ArcRepository arcRepository) {
public ArcService(ArcRepository arcRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository) {
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
}
/** Compte des entités qui seront supprimées en cascade avec l'arc. */
public record DeletionImpact(int chapters, int scenes) {}
public Arc createArc(String name, String description, String campaignId, int order) {
Arc arc = Arc.builder()
.name(name)
@@ -59,7 +72,31 @@ public class ArcService {
return arcRepository.save(arc);
}
/**
* Calcule l'impact d'une suppression en cascade : chapitres + scènes
* qui disparaîtront avec l'arc.
*/
public DeletionImpact getDeletionImpact(String id) {
List<Chapter> chapters = chapterRepository.findByArcId(id);
int sceneTotal = 0;
for (Chapter chapter : chapters) {
sceneTotal += sceneRepository.findByChapterId(chapter.getId()).size();
}
return new DeletionImpact(chapters.size(), sceneTotal);
}
/**
* Supprime l'arc et toutes ses entités dépendantes (chapitres → scènes).
* Transactionnel : atomique.
*/
@Transactional
public void deleteArc(String id) {
for (Chapter chapter : chapterRepository.findByArcId(id)) {
for (var scene : sceneRepository.findByChapterId(chapter.getId())) {
sceneRepository.deleteById(scene.getId());
}
chapterRepository.deleteById(chapter.getId());
}
arcRepository.deleteById(id);
}

View File

@@ -2,8 +2,10 @@ package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@@ -17,11 +19,16 @@ import java.util.Optional;
public class ChapterService {
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
public ChapterService(ChapterRepository chapterRepository) {
public ChapterService(ChapterRepository chapterRepository, SceneRepository sceneRepository) {
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
}
/** Compte des scènes qui seront supprimées en cascade avec le chapitre. */
public record DeletionImpact(int scenes) {}
public Chapter createChapter(String name, String description, String arcId, int order) {
Chapter chapter = Chapter.builder()
.name(name)
@@ -58,7 +65,17 @@ public class ChapterService {
return chapterRepository.save(chapter);
}
/** Compte des scènes qui tomberont avec le chapitre. */
public DeletionImpact getDeletionImpact(String id) {
return new DeletionImpact(sceneRepository.findByChapterId(id).size());
}
/** Supprime le chapitre et toutes ses scènes. Transactionnel : atomique. */
@Transactional
public void deleteChapter(String id) {
for (var scene : sceneRepository.findByChapterId(id)) {
sceneRepository.deleteById(scene.getId());
}
chapterRepository.deleteById(id);
}

View File

@@ -68,4 +68,12 @@ public class ArcController {
arcService.deleteArc(id);
return ResponseEntity.noContent().build();
}
@GetMapping("/{id}/deletion-impact")
public ResponseEntity<ArcService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
if (!arcService.arcExists(id)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(arcService.getDeletionImpact(id));
}
}

View File

@@ -68,4 +68,12 @@ public class ChapterController {
chapterService.deleteChapter(id);
return ResponseEntity.noContent().build();
}
@GetMapping("/{id}/deletion-impact")
public ResponseEntity<ChapterService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
if (!chapterService.chapterExists(id)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(chapterService.getDeletionImpact(id));
}
}

View File

@@ -1,7 +1,11 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -14,6 +18,7 @@ import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
/**
@@ -26,6 +31,10 @@ public class ArcServiceTest {
@Mock
private ArcRepository arcRepository;
@Mock
private ChapterRepository chapterRepository;
@Mock
private SceneRepository sceneRepository;
@InjectMocks
private ArcService arcService;
@@ -159,15 +168,48 @@ public class ArcServiceTest {
}
@Test
void testDeleteArc() {
// Arrange
doNothing().when(arcRepository).deleteById("arc-1");
// Act
void testDeleteArc_EmptyArc() {
// Aucun chapitre : Mockito renvoie List.of() par défaut.
arcService.deleteArc("arc-1");
// Assert
verify(arcRepository, times(1)).deleteById("arc-1");
verify(arcRepository).deleteById("arc-1");
verify(chapterRepository, never()).deleteById(anyString());
verify(sceneRepository, never()).deleteById(anyString());
}
@Test
void testDeleteArc_CascadesChaptersAndScenes() {
Chapter chapter = Chapter.builder().id("chap-1").arcId("arc-1").name("C").build();
Scene s1 = Scene.builder().id("s-1").chapterId("chap-1").name("S1").build();
Scene s2 = Scene.builder().id("s-2").chapterId("chap-1").name("S2").build();
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(chapter));
when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of(s1, s2));
arcService.deleteArc("arc-1");
verify(sceneRepository).deleteById("s-1");
verify(sceneRepository).deleteById("s-2");
verify(chapterRepository).deleteById("chap-1");
verify(arcRepository).deleteById("arc-1");
}
@Test
void testGetDeletionImpact() {
Chapter c1 = Chapter.builder().id("chap-1").arcId("arc-1").name("C1").build();
Chapter c2 = Chapter.builder().id("chap-2").arcId("arc-1").name("C2").build();
Scene s1 = Scene.builder().id("s-1").chapterId("chap-1").name("S1").build();
Scene s2 = Scene.builder().id("s-2").chapterId("chap-2").name("S2").build();
Scene s3 = Scene.builder().id("s-3").chapterId("chap-2").name("S3").build();
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(c1, c2));
when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of(s1));
when(sceneRepository.findByChapterId("chap-2")).thenReturn(List.of(s2, s3));
ArcService.DeletionImpact impact = arcService.getDeletionImpact("arc-1");
assertEquals(2, impact.chapters());
assertEquals(3, impact.scenes());
}
@Test

View File

@@ -1,7 +1,9 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -14,6 +16,7 @@ import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
/**
@@ -26,6 +29,8 @@ public class ChapterServiceTest {
@Mock
private ChapterRepository chapterRepository;
@Mock
private SceneRepository sceneRepository;
@InjectMocks
private ChapterService chapterService;
@@ -157,15 +162,36 @@ public class ChapterServiceTest {
}
@Test
void testDeleteChapter() {
// Arrange
doNothing().when(chapterRepository).deleteById("chapter-1");
// Act
void testDeleteChapter_EmptyChapter() {
// Aucune scène : Mockito renvoie List.of() par défaut.
chapterService.deleteChapter("chapter-1");
// Assert
verify(chapterRepository, times(1)).deleteById("chapter-1");
verify(chapterRepository).deleteById("chapter-1");
verify(sceneRepository, never()).deleteById(anyString());
}
@Test
void testDeleteChapter_CascadesScenes() {
Scene s1 = Scene.builder().id("s-1").chapterId("chapter-1").name("S1").build();
Scene s2 = Scene.builder().id("s-2").chapterId("chapter-1").name("S2").build();
when(sceneRepository.findByChapterId("chapter-1")).thenReturn(List.of(s1, s2));
chapterService.deleteChapter("chapter-1");
verify(sceneRepository).deleteById("s-1");
verify(sceneRepository).deleteById("s-2");
verify(chapterRepository).deleteById("chapter-1");
}
@Test
void testGetDeletionImpact() {
Scene s1 = Scene.builder().id("s-1").chapterId("chapter-1").name("S1").build();
Scene s2 = Scene.builder().id("s-2").chapterId("chapter-1").name("S2").build();
when(sceneRepository.findByChapterId("chapter-1")).thenReturn(List.of(s1, s2));
ChapterService.DeletionImpact impact = chapterService.getDeletionImpact("chapter-1");
assertEquals(2, impact.scenes());
}
@Test

View File

@@ -5,6 +5,7 @@ export const routes: Routes = [
{ path: 'lore/:id', loadComponent: () => import('./lore/lore-detail/lore-detail.component').then(m => m.LoreDetailComponent) },
{ path: 'lore/:loreId/nodes/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) },
{ path: 'lore/:loreId/folders/:parentId/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) },
{ path: 'lore/:loreId/folders/:folderId', loadComponent: () => import('./lore/folder-view/folder-view.component').then(m => m.FolderViewComponent) },
{ path: 'lore/:loreId/folders/:folderId/edit', loadComponent: () => import('./lore/lore-node-edit/lore-node-edit.component').then(m => m.LoreNodeEditComponent) },
{ path: 'lore/:loreId/templates/create', loadComponent: () => import('./lore/template-create/template-create.component').then(m => m.TemplateCreateComponent) },
{ path: 'lore/:loreId/templates/:templateId', loadComponent: () => import('./lore/template-edit/template-edit.component').then(m => m.TemplateEditComponent) },

View File

@@ -10,6 +10,10 @@
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
Modifier
</button>
<button type="button" class="btn-danger" (click)="deleteArc()" title="Supprimer l'arc et tout son contenu">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</header>

View File

@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Pencil } from 'lucide-angular';
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { PageService } from '../../services/page.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
@@ -27,6 +27,7 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
})
export class ArcViewComponent implements OnInit, OnDestroy {
readonly Pencil = Pencil;
readonly Trash2 = Trash2;
campaignId = '';
arcId = '';
@@ -101,6 +102,38 @@ export class ArcViewComponent implements OnInit, OnDestroy {
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'edit']);
}
/**
* Suppression en cascade : récupère d'abord le compte de chapitres / scènes
* qui tomberont avec l'arc, l'annonce dans la confirmation, puis délègue au
* backend (transaction atomique).
*/
deleteArc(): void {
if (!this.arc) return;
const arc = this.arc;
this.campaignService.getArcDeletionImpact(arc.id!).subscribe({
next: impact => {
const parts: string[] = [];
if (impact.chapters > 0) parts.push(`${impact.chapters} chapitre${impact.chapters > 1 ? 's' : ''}`);
if (impact.scenes > 0) parts.push(`${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}`);
const lines = [`Supprimer l'arc "${arc.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.campaignService.deleteArc(arc.id!).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId]),
error: () => console.error('Erreur lors de la suppression de l\'arc')
});
},
error: () => console.error('Impossible de récupérer les dépendances de l\'arc')
});
}
ngOnDestroy(): void {
this.layoutService.hide();
}

View File

@@ -74,6 +74,15 @@
}
.detail-header {
// Sticky : Modifier/Supprimer restent accessibles pendant le scroll de la
// campagne (potentiellement très longue avec arcs / chapitres / scènes).
position: sticky;
top: 0;
z-index: 10;
background: #0a0a14;
padding: 1rem 0;
border-bottom: 1px solid #1f2937;
margin-bottom: 1.5rem;
display: flex;
align-items: flex-start;
justify-content: space-between;

View File

@@ -15,6 +15,10 @@
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
Modifier
</button>
<button type="button" class="btn-danger" (click)="deleteChapter()" title="Supprimer le chapitre et ses scènes">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</header>

View File

@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Pencil, Network } from 'lucide-angular';
import { LucideAngularModule, Pencil, Network, Trash2 } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { PageService } from '../../services/page.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
@@ -27,6 +27,7 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
export class ChapterViewComponent implements OnInit, OnDestroy {
readonly Pencil = Pencil;
readonly Network = Network;
readonly Trash2 = Trash2;
campaignId = '';
arcId = '';
@@ -112,6 +113,33 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
]);
}
/**
* Suppression en cascade : récupère le compte de scènes qui tomberont avec
* le chapitre, l'annonce dans la confirmation, puis délègue au backend.
*/
deleteChapter(): void {
if (!this.chapter) return;
const chapter = this.chapter;
this.campaignService.getChapterDeletionImpact(chapter.id!).subscribe({
next: impact => {
const lines = [`Supprimer le chapitre "${chapter.name}" ?`];
if (impact.scenes > 0) {
lines.push('');
lines.push(`Cette action supprimera aussi : ${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}.`);
}
lines.push('');
lines.push('Cette action est irréversible.');
if (!confirm(lines.join('\n'))) return;
this.campaignService.deleteChapter(chapter.id!).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]),
error: () => console.error('Erreur lors de la suppression du chapitre')
});
},
error: () => console.error('Impossible de récupérer les dépendances du chapitre')
});
}
ngOnDestroy(): void {
this.layoutService.hide();
}

View File

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

View File

@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Pencil } from 'lucide-angular';
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { PageService } from '../../services/page.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
@@ -26,6 +26,7 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
})
export class SceneViewComponent implements OnInit, OnDestroy {
readonly Pencil = Pencil;
readonly Trash2 = Trash2;
campaignId = '';
arcId = '';
@@ -110,6 +111,19 @@ export class SceneViewComponent implements OnInit, OnDestroy {
]);
}
/** Suppression simple — une scène n'a pas d'enfants. Retour au chapitre parent. */
deleteScene(): void {
if (!this.scene) return;
const scene = this.scene;
if (!confirm(`Supprimer la scène "${scene.name}" ?\n\nCette action est irréversible.`)) return;
this.campaignService.deleteScene(scene.id!).subscribe({
next: () => this.router.navigate([
'/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId
]),
error: () => console.error('Erreur lors de la suppression de la scène')
});
}
ngOnDestroy(): void {
this.layoutService.hide();
}

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();
}

View File

@@ -11,6 +11,17 @@ export interface CampaignDeletionImpact {
characters: number;
}
/** Compte des entités qui seront supprimées en cascade avec un arc. */
export interface ArcDeletionImpact {
chapters: number;
scenes: number;
}
/** Compte des scènes qui tomberont avec un chapitre. */
export interface ChapterDeletionImpact {
scenes: number;
}
/**
* Service HTTP pour la gestion des Campagnes.
* Port de sortie vers le Backend Java (Architecture Hexagonale).
@@ -68,6 +79,10 @@ export class CampaignService {
return this.http.delete<void>(`http://localhost:8080/api/arcs/${id}`);
}
getArcDeletionImpact(id: string): Observable<ArcDeletionImpact> {
return this.http.get<ArcDeletionImpact>(`http://localhost:8080/api/arcs/${id}/deletion-impact`);
}
// ========== CHAPTER ==========
getChapters(arcId: string): Observable<Chapter[]> {
return this.http.get<Chapter[]>(`http://localhost:8080/api/chapters/arc/${arcId}`);
@@ -89,6 +104,10 @@ export class CampaignService {
return this.http.delete<void>(`http://localhost:8080/api/chapters/${id}`);
}
getChapterDeletionImpact(id: string): Observable<ChapterDeletionImpact> {
return this.http.get<ChapterDeletionImpact>(`http://localhost:8080/api/chapters/${id}/deletion-impact`);
}
// ========== SCENE ==========
getScenes(chapterId: string): Observable<Scene[]> {
return this.http.get<Scene[]>(`http://localhost:8080/api/scenes/chapter/${chapterId}`);

View File

@@ -17,7 +17,7 @@ export interface BreadcrumbItem {
* Utilisation type :
* <app-breadcrumb [items]="[
* { label: 'Mon Univers', route: ['/lore', loreId] },
* { label: 'PNJ', route: ['/lore', loreId, 'folders', nodeId, 'edit'] },
* { label: 'PNJ', route: ['/lore', loreId, 'folders', nodeId] },
* { label: 'Aldric' }
* ]"></app-breadcrumb>
*/

View File

@@ -131,7 +131,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
title: n.name,
subtitle: '',
tag: 'Dossier',
route: ['/lore', n.loreId, 'folders', n.id, 'edit']
route: ['/lore', n.loreId, 'folders', n.id]
}));
const templateResults: SearchResult[] = templates.map(t => ({
id: t.id,

View File

@@ -43,7 +43,10 @@
</button>
<span *ngIf="item.isAction || !isExpandable(item)" class="chevron-spacer"></span>
<button type="button" class="tree-btn" [class.action]="item.isAction" (click)="clickItem(item)">
<button type="button" class="tree-btn"
[class.action]="item.isAction"
[class.active]="isActive(item)"
(click)="clickItem(item)">
<lucide-icon
*ngIf="iconFor(item) as icon"
[img]="icon"

View File

@@ -129,6 +129,17 @@
&.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;

View File

@@ -188,6 +188,22 @@ export class SecondarySidebarComponent implements OnDestroy {
return this.hasChildren(item);
}
/**
* True si la route du node correspond exactement à l'URL courante. Utilisé
* pour surligner le dossier / page / scène en cours dans l'arbre — utile
* quand plusieurs entrées partagent le même label (ex : deux sous-dossiers
* "test" dans la même arborescence).
*/
isActive(item: TreeItem): boolean {
if (!item.route) return false;
return this.router.isActive(item.route, {
paths: 'exact',
queryParams: 'ignored',
fragment: 'ignored',
matrixParams: 'ignored'
});
}
/**
* Auto-déplie la chaîne d'ancêtres du item dont `route` matche l'URL active.
* Nécessaire car la sidebar est détruite/recréée à chaque navigation (ngIf

View File

@@ -63,8 +63,18 @@
// --------------------------------------------------------------------------
// Header de page "create" / "edit" (titre + éventuel sous-titre)
// --------------------------------------------------------------------------
// Sticky : sur les formulaires longs, les boutons (Sauvegarder / Annuler /
// Supprimer) restent toujours accessibles sans devoir scroller. Le fond opaque
// masque le contenu qui scrolle dessous, et le border-bottom crée une
// séparation visuelle dès qu'il y a du contenu sous le header.
.page-header {
margin-bottom: 2rem;
position: sticky;
top: 0;
z-index: 10;
background: #0a0a14;
padding: 1rem 0;
margin-bottom: 1.5rem;
border-bottom: 1px solid #1f2937;
h1 {
font-size: 1.5rem;

View File

@@ -15,12 +15,19 @@
max-width: 1000px;
// En-tête : titre + sous-titre + boutons d'action (Modifier, Supprimer...)
// Sticky pour que les actions restent accessibles sur les longues fiches.
.view-header {
position: sticky;
top: 0;
z-index: 10;
background: #0a0a14;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 2rem;
padding: 1rem 0;
margin-bottom: 1.5rem;
border-bottom: 1px solid #1f2937;
h1 {
font-size: 1.8rem;