Mise en ligne de la version 0.2.0
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
<div class="page" *ngIf="node">
|
||||
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Éditer le dossier</h1>
|
||||
<p class="subtitle">
|
||||
{{ childFolderCount }} sous-dossier(s) · {{ pageCount }} page(s)
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
[disabled]="!canDelete"
|
||||
[title]="canDelete ? 'Supprimer le dossier' : 'Impossible : le dossier contient des éléments'"
|
||||
(click)="delete()">
|
||||
Supprimer
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary"
|
||||
[disabled]="form.invalid"
|
||||
(click)="save()">
|
||||
Sauvegarder
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form [formGroup]="form" class="edit-form">
|
||||
|
||||
<div class="field">
|
||||
<label>Nom du dossier *</label>
|
||||
<input type="text" formControlName="name" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Dossier parent <span class="optional">(optionnel)</span></label>
|
||||
<select formControlName="parentId">
|
||||
<option value="">— Racine du Lore —</option>
|
||||
<option *ngFor="let parent of availableParents" [value]="parent.id">{{ parent.name }}</option>
|
||||
</select>
|
||||
<p class="hint">Vous ne pouvez pas choisir un sous-dossier du dossier courant (cycle interdit)</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Icône</label>
|
||||
<div class="icon-grid">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
*ngFor="let option of iconOptions"
|
||||
[class.selected]="selectedIcon === option.key"
|
||||
(click)="selectIcon(option.key)">
|
||||
<lucide-icon [img]="option.icon" [size]="18"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box" *ngIf="!canDelete">
|
||||
⚠️ Pour supprimer ce dossier, videz-le d'abord : déplacez ou supprimez ses
|
||||
{{ childFolderCount }} sous-dossier(s) et ses {{ pageCount }} page(s).
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
141
web/src/app/lore/lore-node-edit/lore-node-edit.component.scss
Normal file
141
web/src/app/lore/lore-node-edit/lore-node-edit.component.scss
Normal file
@@ -0,0 +1,141 @@
|
||||
.page {
|
||||
padding: 2rem 3rem;
|
||||
max-width: 860px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
.subtitle {
|
||||
color: #9ca3af;
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
|
||||
label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.optional { color: #6b7280; font-weight: 400; }
|
||||
|
||||
input, select {
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
color: white;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #6c63ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.76rem;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.icon-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(10, 1fr);
|
||||
gap: 0.5rem;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
border-radius: 6px;
|
||||
color: #d1d5db;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
|
||||
&:hover { border-color: #3a3a55; background: #20203a; }
|
||||
|
||||
&.selected {
|
||||
border-color: #6c63ff;
|
||||
background: #1e1c3a;
|
||||
color: #a5b4fc;
|
||||
}
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
border-left: 3px solid #fca5a5;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
color: #d1d5db;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.btn-primary, .btn-secondary, .btn-danger {
|
||||
padding: 0.6rem 1.1rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #6c63ff;
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) { background: #5a52e0; }
|
||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #2a2a3d;
|
||||
color: #d1d5db;
|
||||
|
||||
&:hover { background: #363650; }
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #3f1f1f;
|
||||
color: #fca5a5;
|
||||
|
||||
&:hover:not(:disabled) { background: #5a2a2a; }
|
||||
&:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
}
|
||||
163
web/src/app/lore/lore-node-edit/lore-node-edit.component.ts
Normal file
163
web/src/app/lore/lore-node-edit/lore-node-edit.component.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule, LucideIconData } 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 { LoreNode } from '../../services/lore.model';
|
||||
import { Page } from '../../services/page.model';
|
||||
import {
|
||||
loadLoreSidebarData,
|
||||
buildLoreSidebarConfig,
|
||||
collectDescendantIds
|
||||
} from '../lore-sidebar.helper';
|
||||
import { LORE_ICON_OPTIONS, IconOption } from '../lore-icons';
|
||||
|
||||
/**
|
||||
* Écran d'édition d'un dossier (LoreNode) existant.
|
||||
*
|
||||
* Fonctionnalités :
|
||||
* - Renommer
|
||||
* - Changer l'icône
|
||||
* - Déplacer dans un autre dossier parent (ou vers la racine)
|
||||
* - Supprimer (refusé si le dossier contient des sous-dossiers ou des pages)
|
||||
*
|
||||
* Prévention des cycles : le select "Dossier parent" exclut le dossier en cours
|
||||
* d'édition ET tous ses descendants — sinon l'arbre deviendrait circulaire.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-lore-node-edit',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule],
|
||||
templateUrl: './lore-node-edit.component.html',
|
||||
styleUrls: ['./lore-node-edit.component.scss']
|
||||
})
|
||||
export class LoreNodeEditComponent implements OnInit, OnDestroy {
|
||||
|
||||
readonly iconOptions: IconOption[] = LORE_ICON_OPTIONS;
|
||||
|
||||
form: FormGroup;
|
||||
loreId = '';
|
||||
folderId = '';
|
||||
node: LoreNode | null = null;
|
||||
/** Dossiers proposables comme parent (tous sauf soi-même + descendants). */
|
||||
availableParents: LoreNode[] = [];
|
||||
/** Nombre de sous-dossiers directs (pour affichage + validation de suppression). */
|
||||
childFolderCount = 0;
|
||||
/** Nombre de pages dans ce dossier (pour affichage + validation de suppression). */
|
||||
pageCount = 0;
|
||||
|
||||
selectedIcon: string | null = null;
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private loreService: LoreService,
|
||||
private templateService: TemplateService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
parentId: ['']
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
|
||||
this.folderId = this.route.snapshot.paramMap.get('folderId')!;
|
||||
|
||||
// Réagir aux changements de :folderId (navigation entre dossiers dans la sidebar
|
||||
// sans démonter le composant).
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
const newId = pm.get('folderId')!;
|
||||
if (newId !== this.folderId) {
|
||||
this.folderId = newId;
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
|
||||
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.hydrate(node, sidebar.nodes, sidebar.pages);
|
||||
});
|
||||
}
|
||||
|
||||
private hydrate(node: LoreNode, allNodes: LoreNode[], allPages: Page[]): void {
|
||||
this.node = node;
|
||||
this.selectedIcon = node.icon ?? null;
|
||||
this.form.patchValue({
|
||||
name: node.name,
|
||||
parentId: node.parentId ?? ''
|
||||
});
|
||||
|
||||
// Liste des parents autorisés : tous les dossiers sauf soi + descendants.
|
||||
const excluded = collectDescendantIds(node.id!, allNodes);
|
||||
this.availableParents = allNodes.filter(n => !excluded.has(n.id!));
|
||||
|
||||
// Stats pour affichage + règle de suppression.
|
||||
this.childFolderCount = allNodes.filter(n => n.parentId === node.id).length;
|
||||
this.pageCount = allPages.filter(p => p.nodeId === node.id).length;
|
||||
this.pageTitleService.set(node.name);
|
||||
}
|
||||
|
||||
selectIcon(key: string): void {
|
||||
this.selectedIcon = key;
|
||||
}
|
||||
|
||||
save(): void {
|
||||
if (this.form.invalid || !this.node) return;
|
||||
const raw = this.form.value;
|
||||
const updated: LoreNode = {
|
||||
...this.node,
|
||||
name: raw.name,
|
||||
icon: this.selectedIcon,
|
||||
parentId: raw.parentId && raw.parentId !== '' ? raw.parentId : null
|
||||
};
|
||||
this.loreService.updateLoreNode(this.folderId, updated).subscribe({
|
||||
next: () => this.router.navigate(['/lore', this.loreId]),
|
||||
error: () => console.error('Erreur lors de la sauvegarde du dossier')
|
||||
});
|
||||
}
|
||||
|
||||
get canDelete(): boolean {
|
||||
return this.childFolderCount === 0 && this.pageCount === 0;
|
||||
}
|
||||
|
||||
delete(): void {
|
||||
if (!this.canDelete || !this.node) return;
|
||||
if (!confirm(`Supprimer le dossier "${this.node.name}" ?`)) return;
|
||||
this.loreService.deleteLoreNode(this.folderId).subscribe({
|
||||
next: () => this.router.navigate(['/lore', this.loreId]),
|
||||
error: () => console.error('Erreur lors de la suppression du dossier')
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.router.navigate(['/lore', this.loreId]);
|
||||
}
|
||||
|
||||
/** Retourne l'icône lucide à afficher dans l'aperçu du bouton "Sauvegarder". */
|
||||
getIcon(key: string | null): LucideIconData | null {
|
||||
if (!key) return null;
|
||||
return this.iconOptions.find(o => o.key === key)?.icon ?? null;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user