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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
85
web/src/app/lore/folder-view/folder-view.component.html
Normal file
85
web/src/app/lore/folder-view/folder-view.component.html
Normal 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>
|
||||
154
web/src/app/lore/folder-view/folder-view.component.scss
Normal file
154
web/src/app/lore/folder-view/folder-view.component.scss
Normal 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;
|
||||
}
|
||||
179
web/src/app/lore/folder-view/folder-view.component.ts
Normal file
179
web/src/app/lore/folder-view/folder-view.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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 ───────────────
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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". */
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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]
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user