From 8efdf5d0e0bec96ab6ddb787b283c3c46bfc1824 Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Thu, 23 Apr 2026 14:06:50 +0200 Subject: [PATCH] =?UTF-8?q?Correction=20bug=20suppression=20compl=C3=A8te?= =?UTF-8?q?=20cot=C3=A9=20lore=20(et=20suppression=20dans=20tout=20ce=20qu?= =?UTF-8?q?i=20est=20campagne=20de=20la=20partie=20lore=20li=C3=A9e).=20Am?= =?UTF-8?q?=C3=A9liorations=20ux=20:=20-=20Bandeau=20en=20haut=20qui=20res?= =?UTF-8?q?te=20accessible=20lors=20de=20la=20cr=C3=A9ation=20d'un=20?= =?UTF-8?q?=C3=A9l=C3=A9ment=20(chapitre,=20page,=20sc=C3=A8ne=20etc...)?= =?UTF-8?q?=20-=20Mise=20en=20place=20d'un=20surlignage=20pour=20voir=20su?= =?UTF-8?q?=20quel=20=C3=A9l=C3=A9ment=20on=20est=20positionn=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../campaigncontext/ArcService.java | 39 +++- .../campaigncontext/ChapterService.java | 19 +- .../web/controller/ArcController.java | 8 + .../web/controller/ChapterController.java | 8 + .../campaigncontext/ArcServiceTest.java | 56 +++++- .../campaigncontext/ChapterServiceTest.java | 40 +++- web/src/app/app.routes.ts | 1 + .../arc-view/arc-view.component.html | 4 + .../campaigns/arc-view/arc-view.component.ts | 35 +++- .../campaign-detail.component.scss | 9 + .../chapter-view/chapter-view.component.html | 4 + .../chapter-view/chapter-view.component.ts | 30 ++- .../scene-view/scene-view.component.html | 4 + .../scene-view/scene-view.component.ts | 16 +- .../folder-view/folder-view.component.html | 85 +++++++++ .../folder-view/folder-view.component.scss | 154 +++++++++++++++ .../lore/folder-view/folder-view.component.ts | 179 ++++++++++++++++++ .../lore-detail/lore-detail.component.scss | 10 +- .../lore/lore-detail/lore-detail.component.ts | 2 +- .../lore-node-edit.component.html | 7 - .../lore-node-edit.component.ts | 38 +--- web/src/app/lore/lore-sidebar.helper.ts | 2 +- .../app/lore/page-edit/page-edit.component.ts | 2 +- .../lore/page-view/page-view.component.html | 4 + .../app/lore/page-view/page-view.component.ts | 25 ++- web/src/app/services/campaign.service.ts | 19 ++ .../shared/breadcrumb/breadcrumb.component.ts | 2 +- .../global-search/global-search.component.ts | 2 +- .../secondary-sidebar.component.html | 5 +- .../secondary-sidebar.component.scss | 11 ++ .../secondary-sidebar.component.ts | 16 ++ web/src/styles/_forms.scss | 12 +- web/src/styles/_view.scss | 9 +- 33 files changed, 786 insertions(+), 71 deletions(-) create mode 100644 web/src/app/lore/folder-view/folder-view.component.html create mode 100644 web/src/app/lore/folder-view/folder-view.component.scss create mode 100644 web/src/app/lore/folder-view/folder-view.component.ts diff --git a/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java b/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java index 0f08001..1aabcbb 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java @@ -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 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); } diff --git a/core/src/main/java/com/loremind/application/campaigncontext/ChapterService.java b/core/src/main/java/com/loremind/application/campaigncontext/ChapterService.java index 1fd710a..f60d6f5 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/ChapterService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/ChapterService.java @@ -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); } diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/ArcController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/ArcController.java index 25afd9a..75b0394 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/ArcController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/ArcController.java @@ -68,4 +68,12 @@ public class ArcController { arcService.deleteArc(id); return ResponseEntity.noContent().build(); } + + @GetMapping("/{id}/deletion-impact") + public ResponseEntity getDeletionImpact(@PathVariable String id) { + if (!arcService.arcExists(id)) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(arcService.getDeletionImpact(id)); + } } diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/ChapterController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/ChapterController.java index 65b8558..486d6d1 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/ChapterController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/ChapterController.java @@ -68,4 +68,12 @@ public class ChapterController { chapterService.deleteChapter(id); return ResponseEntity.noContent().build(); } + + @GetMapping("/{id}/deletion-impact") + public ResponseEntity getDeletionImpact(@PathVariable String id) { + if (!chapterService.chapterExists(id)) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(chapterService.getDeletionImpact(id)); + } } diff --git a/core/src/test/java/com/loremind/application/campaigncontext/ArcServiceTest.java b/core/src/test/java/com/loremind/application/campaigncontext/ArcServiceTest.java index 8832e46..cc41e7e 100644 --- a/core/src/test/java/com/loremind/application/campaigncontext/ArcServiceTest.java +++ b/core/src/test/java/com/loremind/application/campaigncontext/ArcServiceTest.java @@ -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 diff --git a/core/src/test/java/com/loremind/application/campaigncontext/ChapterServiceTest.java b/core/src/test/java/com/loremind/application/campaigncontext/ChapterServiceTest.java index 53f11a5..b535489 100644 --- a/core/src/test/java/com/loremind/application/campaigncontext/ChapterServiceTest.java +++ b/core/src/test/java/com/loremind/application/campaigncontext/ChapterServiceTest.java @@ -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 diff --git a/web/src/app/app.routes.ts b/web/src/app/app.routes.ts index 8477770..79cad5c 100644 --- a/web/src/app/app.routes.ts +++ b/web/src/app/app.routes.ts @@ -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) }, diff --git a/web/src/app/campaigns/arc-view/arc-view.component.html b/web/src/app/campaigns/arc-view/arc-view.component.html index e6d025c..a1c1d3d 100644 --- a/web/src/app/campaigns/arc-view/arc-view.component.html +++ b/web/src/app/campaigns/arc-view/arc-view.component.html @@ -10,6 +10,10 @@ Modifier + diff --git a/web/src/app/campaigns/arc-view/arc-view.component.ts b/web/src/app/campaigns/arc-view/arc-view.component.ts index a1fd6da..a1d186e 100644 --- a/web/src/app/campaigns/arc-view/arc-view.component.ts +++ b/web/src/app/campaigns/arc-view/arc-view.component.ts @@ -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(); } diff --git a/web/src/app/campaigns/campaign-detail/campaign-detail.component.scss b/web/src/app/campaigns/campaign-detail/campaign-detail.component.scss index 0589780..def1f60 100644 --- a/web/src/app/campaigns/campaign-detail/campaign-detail.component.scss +++ b/web/src/app/campaigns/campaign-detail/campaign-detail.component.scss @@ -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; diff --git a/web/src/app/campaigns/chapter-view/chapter-view.component.html b/web/src/app/campaigns/chapter-view/chapter-view.component.html index edbe576..73cbdbe 100644 --- a/web/src/app/campaigns/chapter-view/chapter-view.component.html +++ b/web/src/app/campaigns/chapter-view/chapter-view.component.html @@ -15,6 +15,10 @@ Modifier + diff --git a/web/src/app/campaigns/chapter-view/chapter-view.component.ts b/web/src/app/campaigns/chapter-view/chapter-view.component.ts index c46ec51..8b6b56c 100644 --- a/web/src/app/campaigns/chapter-view/chapter-view.component.ts +++ b/web/src/app/campaigns/chapter-view/chapter-view.component.ts @@ -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(); } diff --git a/web/src/app/campaigns/scene-view/scene-view.component.html b/web/src/app/campaigns/scene-view/scene-view.component.html index e69e74c..b9c65fb 100644 --- a/web/src/app/campaigns/scene-view/scene-view.component.html +++ b/web/src/app/campaigns/scene-view/scene-view.component.html @@ -10,6 +10,10 @@ Modifier + diff --git a/web/src/app/campaigns/scene-view/scene-view.component.ts b/web/src/app/campaigns/scene-view/scene-view.component.ts index f7d9622..d3f38ad 100644 --- a/web/src/app/campaigns/scene-view/scene-view.component.ts +++ b/web/src/app/campaigns/scene-view/scene-view.component.ts @@ -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(); } diff --git a/web/src/app/lore/folder-view/folder-view.component.html b/web/src/app/lore/folder-view/folder-view.component.html new file mode 100644 index 0000000..44b2545 --- /dev/null +++ b/web/src/app/lore/folder-view/folder-view.component.html @@ -0,0 +1,85 @@ +
+ + + + + +
+
+

+ + {{ node.name }} +

+

+ {{ subfolders.length }} sous-dossier(s) · {{ pages.length }} page(s) +

+
+
+ + +
+
+ + +
+
+

Sous-dossiers

+ +
+ +
+
+ + {{ sub.name }} +
+
+ +
+

Aucun sous-dossier.

+
+
+ + +
+
+

Pages

+ +
+ +
+
+ + {{ page.title }} +
+
+ +
+

Aucune page dans ce dossier.

+
+
+ +
diff --git a/web/src/app/lore/folder-view/folder-view.component.scss b/web/src/app/lore/folder-view/folder-view.component.scss new file mode 100644 index 0000000..976d05f --- /dev/null +++ b/web/src/app/lore/folder-view/folder-view.component.scss @@ -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; +} diff --git a/web/src/app/lore/folder-view/folder-view.component.ts b/web/src/app/lore/folder-view/folder-view.component.ts new file mode 100644 index 0000000..614106e --- /dev/null +++ b/web/src/app/lore/folder-view/folder-view.component.ts @@ -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(); + 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(); + } +} diff --git a/web/src/app/lore/lore-detail/lore-detail.component.scss b/web/src/app/lore/lore-detail/lore-detail.component.scss index 6455e8a..e8dbef4 100644 --- a/web/src/app/lore/lore-detail/lore-detail.component.scss +++ b/web/src/app/lore/lore-detail/lore-detail.component.scss @@ -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; } diff --git a/web/src/app/lore/lore-detail/lore-detail.component.ts b/web/src/app/lore/lore-detail/lore-detail.component.ts index 83b6e27..f662ceb 100644 --- a/web/src/app/lore/lore-detail/lore-detail.component.ts +++ b/web/src/app/lore/lore-detail/lore-detail.component.ts @@ -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 ─────────────── diff --git a/web/src/app/lore/lore-node-edit/lore-node-edit.component.html b/web/src/app/lore/lore-node-edit/lore-node-edit.component.html index 7ee8740..bf80652 100644 --- a/web/src/app/lore/lore-node-edit/lore-node-edit.component.html +++ b/web/src/app/lore/lore-node-edit/lore-node-edit.component.html @@ -9,13 +9,6 @@
- +
diff --git a/web/src/app/lore/page-view/page-view.component.ts b/web/src/app/lore/page-view/page-view.component.ts index ef83653..8bfc62b 100644 --- a/web/src/app/lore/page-view/page-view.component.ts +++ b/web/src/app/lore/page-view/page-view.component.ts @@ -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(); } diff --git a/web/src/app/services/campaign.service.ts b/web/src/app/services/campaign.service.ts index cb5b30b..e3ebb00 100644 --- a/web/src/app/services/campaign.service.ts +++ b/web/src/app/services/campaign.service.ts @@ -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(`http://localhost:8080/api/arcs/${id}`); } + getArcDeletionImpact(id: string): Observable { + return this.http.get(`http://localhost:8080/api/arcs/${id}/deletion-impact`); + } + // ========== CHAPTER ========== getChapters(arcId: string): Observable { return this.http.get(`http://localhost:8080/api/chapters/arc/${arcId}`); @@ -89,6 +104,10 @@ export class CampaignService { return this.http.delete(`http://localhost:8080/api/chapters/${id}`); } + getChapterDeletionImpact(id: string): Observable { + return this.http.get(`http://localhost:8080/api/chapters/${id}/deletion-impact`); + } + // ========== SCENE ========== getScenes(chapterId: string): Observable { return this.http.get(`http://localhost:8080/api/scenes/chapter/${chapterId}`); diff --git a/web/src/app/shared/breadcrumb/breadcrumb.component.ts b/web/src/app/shared/breadcrumb/breadcrumb.component.ts index 6d5c447..0814cac 100644 --- a/web/src/app/shared/breadcrumb/breadcrumb.component.ts +++ b/web/src/app/shared/breadcrumb/breadcrumb.component.ts @@ -17,7 +17,7 @@ export interface BreadcrumbItem { * Utilisation type : * */ diff --git a/web/src/app/shared/global-search/global-search.component.ts b/web/src/app/shared/global-search/global-search.component.ts index 845a94c..580b77e 100644 --- a/web/src/app/shared/global-search/global-search.component.ts +++ b/web/src/app/shared/global-search/global-search.component.ts @@ -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, diff --git a/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.html b/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.html index bc0626f..9839a58 100644 --- a/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.html +++ b/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.html @@ -43,7 +43,10 @@ -