Mise en ligne de la version 0.2.0
This commit is contained in:
70
web/src/app/lore/lore-detail/lore-detail.component.html
Normal file
70
web/src/app/lore/lore-detail/lore-detail.component.html
Normal file
@@ -0,0 +1,70 @@
|
||||
<div class="lore-detail" *ngIf="lore">
|
||||
|
||||
<!-- ============ Header : mode lecture ============ -->
|
||||
<div class="detail-header" *ngIf="!editing">
|
||||
<div class="header-texts">
|
||||
<h1>{{ lore.name }}</h1>
|
||||
<p class="description">{{ lore.description }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-secondary" (click)="startEdit()" title="Modifier le Lore">
|
||||
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
||||
Modifier
|
||||
</button>
|
||||
<button type="button" class="btn-danger" (click)="deleteLore()" title="Supprimer le Lore">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ Header : mode édition inline ============ -->
|
||||
<div class="detail-header edit-mode" *ngIf="editing">
|
||||
<div class="field">
|
||||
<label>Nom</label>
|
||||
<input type="text" [(ngModel)]="editName" name="editName" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Description</label>
|
||||
<textarea [(ngModel)]="editDescription" name="editDescription" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-primary" (click)="saveEdit()" [disabled]="!editName.trim()">
|
||||
Sauvegarder
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancelEdit()">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ Grille des dossiers racine ============ -->
|
||||
<div class="nodes-section">
|
||||
<div class="section-header">
|
||||
<h2>Dossiers</h2>
|
||||
<button class="btn-add" (click)="navigateToCreateNode()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Nouveau dossier
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- rootNodes : uniquement les dossiers racine (pas les sous-dossiers,
|
||||
qui sont visibles dans l'arbre de la sidebar). -->
|
||||
<div class="nodes-grid" *ngIf="rootNodes.length > 0">
|
||||
<div class="node-card" *ngFor="let node of rootNodes" (click)="navigateToFolder(node.id!)">
|
||||
<lucide-icon [img]="Folder" [size]="24" class="node-icon"></lucide-icon>
|
||||
<span class="node-name">{{ node.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" *ngIf="rootNodes.length === 0">
|
||||
<lucide-icon [img]="Folder" [size]="40" class="empty-icon"></lucide-icon>
|
||||
<p>Aucun dossier pour le moment.</p>
|
||||
<button class="btn-add-first" (click)="navigateToCreateNode()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Créer votre premier dossier
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
160
web/src/app/lore/lore-detail/lore-detail.component.scss
Normal file
160
web/src/app/lore/lore-detail/lore-detail.component.scss
Normal file
@@ -0,0 +1,160 @@
|
||||
.lore-detail {
|
||||
padding: 2.5rem 2rem;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2.5rem;
|
||||
|
||||
.header-texts { flex: 1; min-width: 0; }
|
||||
|
||||
h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #6b7280;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Variante mode édition : input / textarea en colonne.
|
||||
&.edit-mode {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
label { color: #9ca3af; font-size: 0.8rem; font-weight: 500; }
|
||||
input, textarea {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
padding: 0.6rem 0.85rem;
|
||||
font-size: 0.9rem;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
|
||||
&:focus { outline: none; border-color: #6c63ff; }
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions { justify-content: flex-end; }
|
||||
}
|
||||
}
|
||||
|
||||
// Boutons partagés du header.
|
||||
.btn-primary, .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;
|
||||
|
||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.btn-primary { background: #6c63ff; color: white; &:hover:not(:disabled) { background: #5b52e0; } }
|
||||
.btn-secondary { background: #1f2937; color: #d1d5db; &:hover:not(:disabled) { background: #374151; } }
|
||||
.btn-danger { background: #3a1e1e; color: #f87171; &:hover:not(:disabled) { background: #5a2e2e; } }
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
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; }
|
||||
}
|
||||
|
||||
.nodes-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; }
|
||||
.node-type { color: #6b7280; font-size: 0.75rem; }
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 4rem 2rem;
|
||||
color: #6b7280;
|
||||
|
||||
.empty-icon { color: #374151; }
|
||||
p { font-size: 0.95rem; }
|
||||
}
|
||||
|
||||
.btn-add-first {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.6rem 1.25rem;
|
||||
background: #6c63ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover { background: #5b52e0; }
|
||||
}
|
||||
137
web/src/app/lore/lore-detail/lore-detail.component.ts
Normal file
137
web/src/app/lore/lore-detail/lore-detail.component.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { LucideAngularModule, Folder, Plus, Pencil, Trash2 } 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 { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lore-detail',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule],
|
||||
templateUrl: './lore-detail.component.html',
|
||||
styleUrls: ['./lore-detail.component.scss']
|
||||
})
|
||||
export class LoreDetailComponent implements OnInit, OnDestroy {
|
||||
readonly Folder = Folder;
|
||||
readonly Plus = Plus;
|
||||
readonly Pencil = Pencil;
|
||||
readonly Trash2 = Trash2;
|
||||
|
||||
lore: Lore | null = null;
|
||||
/** Tous les dossiers du Lore (racines + enfants). */
|
||||
allNodes: LoreNode[] = [];
|
||||
/** Uniquement les dossiers racine — seuls affichés dans la grille principale. */
|
||||
rootNodes: LoreNode[] = [];
|
||||
|
||||
/** Mode édition inline du header (nom + description). */
|
||||
editing = false;
|
||||
editName = '';
|
||||
editDescription = '';
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private loreService: LoreService,
|
||||
private templateService: TemplateService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
// On s'abonne à paramMap (pas snapshot) pour recharger quand on switche
|
||||
// d'un Lore à l'autre via la liste globale de la sidebar — Angular réutilise
|
||||
// le même composant et ngOnInit ne se relance pas tout seul.
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
const id = pm.get('id');
|
||||
if (id && id !== this.lore?.id) {
|
||||
this.load(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private load(id: string): void {
|
||||
loadLoreSidebarData(id, this.loreService, this.templateService, this.pageService).subscribe(data => {
|
||||
this.lore = data.lore;
|
||||
this.allNodes = data.nodes;
|
||||
// Bug d'affichage corrigé : on ne liste ici que les dossiers racine
|
||||
// (les sous-dossiers apparaissent dans l'arbre de la sidebar quand on
|
||||
// ouvre leur parent). parentId null OU chaîne vide = racine.
|
||||
this.rootNodes = data.nodes.filter(n => !n.parentId);
|
||||
this.layoutService.show(buildLoreSidebarConfig(data));
|
||||
this.pageTitleService.set(data.lore.name);
|
||||
// On sort du mode édition si on change de Lore en cours d'édition.
|
||||
this.editing = false;
|
||||
});
|
||||
}
|
||||
|
||||
navigateToCreateNode(): void {
|
||||
this.router.navigate(['/lore', this.lore!.id, 'nodes', 'create']);
|
||||
}
|
||||
|
||||
navigateToFolder(nodeId: string): void {
|
||||
this.router.navigate(['/lore', this.lore!.id, 'folders', nodeId, 'edit']);
|
||||
}
|
||||
|
||||
// ─────────────── Édition / suppression du Lore ───────────────
|
||||
|
||||
startEdit(): void {
|
||||
if (!this.lore) return;
|
||||
this.editName = this.lore.name;
|
||||
this.editDescription = this.lore.description ?? '';
|
||||
this.editing = true;
|
||||
}
|
||||
|
||||
cancelEdit(): void {
|
||||
this.editing = false;
|
||||
}
|
||||
|
||||
saveEdit(): void {
|
||||
if (!this.lore || !this.editName.trim()) return;
|
||||
this.loreService.updateLore(this.lore.id!, {
|
||||
name: this.editName.trim(),
|
||||
description: this.editDescription
|
||||
}).subscribe({
|
||||
next: (updated) => {
|
||||
this.lore = updated;
|
||||
this.editing = false;
|
||||
// Recharge la sidebar pour que le titre soit à jour.
|
||||
this.load(updated.id!);
|
||||
},
|
||||
error: () => console.error('Erreur lors de la mise à jour du Lore')
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppression protégée : refus si le Lore contient encore des dossiers
|
||||
* ou des pages. Protège contre un clic accidentel sur des données
|
||||
* construites longuement. Logique côté frontend (pas d'appel HTTP
|
||||
* supplémentaire) car les données sont déjà chargées.
|
||||
*/
|
||||
deleteLore(): void {
|
||||
if (!this.lore) return;
|
||||
if (this.allNodes.length > 0) {
|
||||
alert(
|
||||
`Impossible de supprimer "${this.lore.name}" : il contient encore ${this.allNodes.length} dossier(s).\n` +
|
||||
`Videz le Lore (dossiers et pages) avant de le supprimer.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Supprimer définitivement le Lore "${this.lore.name}" ?`)) return;
|
||||
this.loreService.deleteLore(this.lore.id!).subscribe({
|
||||
next: () => this.router.navigate(['/lore']),
|
||||
error: () => console.error('Erreur lors de la suppression du Lore')
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user