Mise en ligne de la version 0.2.0
This commit is contained in:
54
web/src/app/lore/lore-create/lore-create.component.html
Normal file
54
web/src/app/lore/lore-create/lore-create.component.html
Normal file
@@ -0,0 +1,54 @@
|
||||
<div class="modal-backdrop" (click)="onCancel()">
|
||||
<div class="modal" (click)="$event.stopPropagation()">
|
||||
|
||||
<div class="modal-header">
|
||||
<h2>Créer un nouveau Lore</h2>
|
||||
<button class="btn-close" (click)="onCancel()">
|
||||
<lucide-icon [img]="X" [size]="18"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()">
|
||||
|
||||
<div class="field">
|
||||
<label>Nom de l'univers *</label>
|
||||
<input
|
||||
type="text"
|
||||
formControlName="name"
|
||||
placeholder="Ex: Royaume des Ombres, Cyberpunk 2157..."
|
||||
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Description</label>
|
||||
<textarea
|
||||
formControlName="description"
|
||||
placeholder="Décrivez brièvement votre univers, son ambiance, son genre..."
|
||||
rows="5"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>💡 Astuce :</strong> Votre lore sera créé avec quelques templates par défaut :</p>
|
||||
<ul>
|
||||
<li>PNJ - Pour vos personnages</li>
|
||||
<li>Lieu - Pour vos villes et régions</li>
|
||||
<li>Faction - Pour vos organisations</li>
|
||||
<li>Objet - Pour vos artefacts</li>
|
||||
</ul>
|
||||
<p class="info-footer">Vous pourrez créer vos propres templates ensuite !</p>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="submit" class="btn-primary" [disabled]="form.invalid">
|
||||
<lucide-icon [img]="BookCopy" [size]="16"></lucide-icon>
|
||||
Créer le lore
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="onCancel()">Annuler</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
123
web/src/app/lore/lore-create/lore-create.component.scss
Normal file
123
web/src/app/lore/lore-create/lore-create.component.scss
Normal file
@@ -0,0 +1,123 @@
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #111827;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
h2 { color: white; font-size: 1.25rem; font-weight: 600; }
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover { color: white; }
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
color: #d1d5db;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
width: 100%;
|
||||
background: #1f2937;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
resize: none;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&::placeholder { color: #4b5563; }
|
||||
&:focus { border-color: #6c63ff; }
|
||||
&.invalid { border-color: #ef4444; }
|
||||
}
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: #1f2937;
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
line-height: 1.6;
|
||||
|
||||
ul {
|
||||
margin: 0.5rem 0 0.5rem 1.25rem;
|
||||
li { margin-bottom: 0.15rem; }
|
||||
}
|
||||
|
||||
.info-footer { color: #6b7280; margin-top: 0.5rem; }
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #6c63ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover:not(:disabled) { background: #5b52e0; }
|
||||
&:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #1f2937;
|
||||
color: #d1d5db;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover { background: #374151; }
|
||||
}
|
||||
37
web/src/app/lore/lore-create/lore-create.component.ts
Normal file
37
web/src/app/lore/lore-create/lore-create.component.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Component, EventEmitter, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { LucideAngularModule, BookCopy, X } from 'lucide-angular';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lore-create',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule],
|
||||
templateUrl: './lore-create.component.html',
|
||||
styleUrls: ['./lore-create.component.scss']
|
||||
})
|
||||
export class LoreCreateComponent {
|
||||
@Output() close = new EventEmitter<void>();
|
||||
@Output() created = new EventEmitter<{ name: string; description: string }>();
|
||||
|
||||
readonly BookCopy = BookCopy;
|
||||
readonly X = X;
|
||||
|
||||
form: FormGroup;
|
||||
|
||||
constructor(private fb: FormBuilder) {
|
||||
this.form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
description: ['']
|
||||
});
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid) return;
|
||||
this.created.emit(this.form.value);
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.close.emit();
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
53
web/src/app/lore/lore-icons.ts
Normal file
53
web/src/app/lore/lore-icons.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
Folder,
|
||||
Users, Swords, MapPin, Shield, Crown, Skull, Gem,
|
||||
BookOpen, Scroll, Wand2, Sparkles, TreePine, Mountain,
|
||||
Ship, Flame, Star, Moon, Key, Globe, Compass, LucideIconData
|
||||
} from 'lucide-angular';
|
||||
|
||||
/**
|
||||
* Registre partagé d'icônes disponibles pour les dossiers (LoreNode).
|
||||
*
|
||||
* Utilisé à la fois par :
|
||||
* - l'écran de création de dossier (grille de sélection)
|
||||
* - la sidebar (résolution `iconKey → LucideIconData` pour afficher l'icône)
|
||||
*
|
||||
* Pourquoi factoriser ? Avant : deux sources de vérité risqueraient de
|
||||
* diverger (ex: ajout d'une icône dans l'un sans l'autre).
|
||||
*/
|
||||
export interface IconOption {
|
||||
key: string;
|
||||
icon: LucideIconData;
|
||||
}
|
||||
|
||||
export const LORE_ICON_OPTIONS: IconOption[] = [
|
||||
{ key: 'users', icon: Users },
|
||||
{ key: 'swords', icon: Swords },
|
||||
{ key: 'map-pin', icon: MapPin },
|
||||
{ key: 'shield', icon: Shield },
|
||||
{ key: 'crown', icon: Crown },
|
||||
{ key: 'skull', icon: Skull },
|
||||
{ key: 'gem', icon: Gem },
|
||||
{ key: 'book-open', icon: BookOpen },
|
||||
{ key: 'scroll', icon: Scroll },
|
||||
{ key: 'wand', icon: Wand2 },
|
||||
{ key: 'sparkles', icon: Sparkles },
|
||||
{ key: 'tree', icon: TreePine },
|
||||
{ key: 'mountain', icon: Mountain },
|
||||
{ key: 'ship', icon: Ship },
|
||||
{ key: 'flame', icon: Flame },
|
||||
{ key: 'star', icon: Star },
|
||||
{ key: 'moon', icon: Moon },
|
||||
{ key: 'key', icon: Key },
|
||||
{ key: 'globe', icon: Globe },
|
||||
{ key: 'compass', icon: Compass },
|
||||
];
|
||||
|
||||
/** Icône par défaut pour un dossier sans icône. */
|
||||
export const DEFAULT_FOLDER_ICON: LucideIconData = Folder;
|
||||
|
||||
/** Résout une clé d'icône en LucideIconData. Fallback : icône dossier par défaut. */
|
||||
export function resolveIcon(key: string | null | undefined): LucideIconData {
|
||||
if (!key) return DEFAULT_FOLDER_ICON;
|
||||
return LORE_ICON_OPTIONS.find(o => o.key === key)?.icon ?? DEFAULT_FOLDER_ICON;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<div class="node-create-page">
|
||||
|
||||
<div class="page-header">
|
||||
<h1>Créer un nouveau dossier</h1>
|
||||
<p class="subtitle">Les dossiers permettent d'organiser vos pages par catégorie</p>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="node-form">
|
||||
|
||||
<div class="field">
|
||||
<label>Nom du dossier *</label>
|
||||
<input
|
||||
type="text"
|
||||
formControlName="name"
|
||||
placeholder="Ex: Personnages, Créatures..."
|
||||
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
|
||||
/>
|
||||
</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">Laissez vide pour créer un dossier à la racine du lore</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="field">
|
||||
<label>Description <span class="optional">(optionnel)</span></label>
|
||||
<textarea
|
||||
formControlName="description"
|
||||
placeholder="Décrivez le type de contenu que ce dossier contiendra..."
|
||||
rows="4">
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Adresse</label>
|
||||
<input
|
||||
type="text"
|
||||
formControlName="address"
|
||||
placeholder="nom-du-dossier"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary" [disabled]="form.invalid">
|
||||
<lucide-icon [img]="getIcon(selectedIcon)" [size]="16"></lucide-icon>
|
||||
Créer le dossier
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,116 @@
|
||||
.node-create-page {
|
||||
padding: 2.5rem 2rem;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2.5rem;
|
||||
|
||||
h1 { font-size: 1.5rem; font-weight: 700; color: white; margin-bottom: 0.4rem; }
|
||||
.subtitle { color: #6b7280; font-size: 0.9rem; }
|
||||
}
|
||||
|
||||
.node-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
label {
|
||||
font-size: 0.875rem;
|
||||
color: #d1d5db;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.optional { color: #6b7280; font-weight: 400; }
|
||||
|
||||
input, textarea, select {
|
||||
background: #1f2937;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
resize: none;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&::placeholder { color: #4b5563; }
|
||||
&:focus { border-color: #6c63ff; }
|
||||
&.invalid { border-color: #ef4444; }
|
||||
}
|
||||
}
|
||||
|
||||
.icon-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
background: #1f2937;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover { background: #374151; color: white; }
|
||||
|
||||
&.selected {
|
||||
background: #1e1b4b;
|
||||
border-color: #6c63ff;
|
||||
color: #a5b4fc;
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #6c63ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover:not(:disabled) { background: #5b52e0; }
|
||||
&:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #1f2937;
|
||||
color: #d1d5db;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover { background: #374151; }
|
||||
}
|
||||
104
web/src/app/lore/lore-node-create/lore-node-create.component.ts
Normal file
104
web/src/app/lore/lore-node-create/lore-node-create.component.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
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 { 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 { LoreNode } from '../../services/lore.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
import { LORE_ICON_OPTIONS, IconOption, resolveIcon } from '../lore-icons';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lore-node-create',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule],
|
||||
templateUrl: './lore-node-create.component.html',
|
||||
styleUrls: ['./lore-node-create.component.scss']
|
||||
})
|
||||
export class LoreNodeCreateComponent implements OnInit, OnDestroy {
|
||||
|
||||
readonly iconOptions: IconOption[] = LORE_ICON_OPTIONS;
|
||||
|
||||
form: FormGroup;
|
||||
loreId = '';
|
||||
/** parentId optionnel depuis la route — si présent, pré-remplit le champ. */
|
||||
preselectedParentId: string | null = null;
|
||||
/** Liste des dossiers existants pour le select "Dossier parent". */
|
||||
availableParents: LoreNode[] = [];
|
||||
selectedIcon = this.iconOptions[0].key;
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private loreService: LoreService,
|
||||
private templateService: TemplateService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
description: [''],
|
||||
address: ['', Validators.required],
|
||||
parentId: [''] // '' = racine
|
||||
});
|
||||
|
||||
// Auto-génère l'adresse depuis le nom
|
||||
this.form.get('name')!.valueChanges.subscribe(name => {
|
||||
const slug = (name as string).toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
this.form.get('address')!.setValue(slug, { emitEvent: false });
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
|
||||
this.preselectedParentId = this.route.snapshot.paramMap.get('parentId');
|
||||
this.loadLayout();
|
||||
}
|
||||
|
||||
private loadLayout(): void {
|
||||
loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService)
|
||||
.subscribe(data => {
|
||||
this.availableParents = data.nodes;
|
||||
if (this.preselectedParentId) {
|
||||
this.form.patchValue({ parentId: this.preselectedParentId });
|
||||
}
|
||||
this.layoutService.show(buildLoreSidebarConfig(data));
|
||||
});
|
||||
}
|
||||
|
||||
selectIcon(key: string): void {
|
||||
this.selectedIcon = key;
|
||||
}
|
||||
|
||||
getIcon(key: string): LucideIconData {
|
||||
return this.iconOptions.find(o => o.key === key)!.icon;
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid) return;
|
||||
const raw = this.form.value;
|
||||
this.loreService.createLoreNode({
|
||||
name: raw.name,
|
||||
description: raw.description,
|
||||
address: raw.address,
|
||||
icon: this.selectedIcon,
|
||||
parentId: raw.parentId && raw.parentId !== '' ? raw.parentId : null,
|
||||
loreId: this.loreId
|
||||
}).subscribe({
|
||||
next: () => this.router.navigate(['/lore', this.loreId]),
|
||||
error: () => console.error('Erreur lors de la création du dossier')
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.router.navigate(['/lore', this.loreId]);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
165
web/src/app/lore/lore-sidebar.helper.ts
Normal file
165
web/src/app/lore/lore-sidebar.helper.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { forkJoin, Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { LoreService } from '../services/lore.service';
|
||||
import { TemplateService } from '../services/template.service';
|
||||
import { PageService } from '../services/page.service';
|
||||
import { Lore, LoreNode } from '../services/lore.model';
|
||||
import { Template } from '../services/template.model';
|
||||
import { Page } from '../services/page.model';
|
||||
import {
|
||||
SecondarySidebarConfig, TreeItem, GlobalItem, BottomPanel
|
||||
} from '../services/layout.service';
|
||||
|
||||
export interface LoreSidebarData {
|
||||
lore: Lore;
|
||||
allLores: Lore[];
|
||||
nodes: LoreNode[];
|
||||
templates: Template[];
|
||||
pages: Page[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge toutes les données nécessaires à la sidebar d'un Lore en parallèle.
|
||||
* Centralise le pattern commun aux écrans lore-detail, lore-node-create,
|
||||
* template-create, template-edit, page-create, page-edit.
|
||||
*/
|
||||
export function loadLoreSidebarData(
|
||||
loreId: string,
|
||||
loreService: LoreService,
|
||||
templateService: TemplateService,
|
||||
pageService: PageService
|
||||
): Observable<LoreSidebarData> {
|
||||
return forkJoin({
|
||||
lore: loreService.getLoreById(loreId),
|
||||
allLores: loreService.getAllLores(),
|
||||
nodes: loreService.getLoreNodes(loreId),
|
||||
templates: templateService.getByLoreId(loreId),
|
||||
pages: pageService.getByLoreId(loreId)
|
||||
}).pipe(
|
||||
map(data => data as LoreSidebarData)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit la config complète de la SecondarySidebar pour un Lore, incluant
|
||||
* le panneau "Templates" en bas.
|
||||
*/
|
||||
export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarConfig {
|
||||
const { lore, allLores, nodes, templates, pages } = data;
|
||||
|
||||
// Regroupe les pages par nodeId et les sous-dossiers par parentId pour un accès O(1).
|
||||
const pagesByNode = new Map<string, Page[]>();
|
||||
for (const p of pages) {
|
||||
const bucket = pagesByNode.get(p.nodeId) ?? [];
|
||||
bucket.push(p);
|
||||
pagesByNode.set(p.nodeId, bucket);
|
||||
}
|
||||
const childrenByParent = new Map<string, LoreNode[]>();
|
||||
for (const n of nodes) {
|
||||
const parentKey = n.parentId ?? '__root__';
|
||||
const bucket = childrenByParent.get(parentKey) ?? [];
|
||||
bucket.push(n);
|
||||
childrenByParent.set(parentKey, bucket);
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit récursivement le TreeItem d'un dossier :
|
||||
* ses sous-dossiers, puis ses pages, puis les actions "+ Nouveau dossier" et "+ Nouvelle page".
|
||||
*/
|
||||
const buildFolderItem = (node: LoreNode): TreeItem => {
|
||||
const subFolders = childrenByParent.get(node.id!) ?? [];
|
||||
const nodePages = pagesByNode.get(node.id!) ?? [];
|
||||
const children: TreeItem[] = [
|
||||
...subFolders.map(buildFolderItem),
|
||||
...nodePages.map(p => ({
|
||||
id: `page-${p.id}`,
|
||||
label: p.title,
|
||||
route: `/lore/${lore.id}/pages/${p.id}`
|
||||
})),
|
||||
{
|
||||
id: `create-folder-${node.id}`,
|
||||
label: '+ Nouveau dossier',
|
||||
isAction: true,
|
||||
route: `/lore/${lore.id}/folders/${node.id}/create`
|
||||
},
|
||||
{
|
||||
id: `create-page-${node.id}`,
|
||||
label: '+ Nouvelle page',
|
||||
isAction: true,
|
||||
route: `/lore/${lore.id}/nodes/${node.id}/pages/create`
|
||||
}
|
||||
];
|
||||
// IDs préfixés par type — chaque entité a sa propre séquence IDENTITY en base,
|
||||
// donc node.id=1 et page.id=1 peuvent coexister et collisionner dans le
|
||||
// Set<string> global de LayoutService.expanded.
|
||||
return {
|
||||
id: `folder-${node.id}`,
|
||||
label: node.name,
|
||||
iconKey: node.icon ?? undefined,
|
||||
route: `/lore/${lore.id}/folders/${node.id}/edit`,
|
||||
meta: nodePages.length > 0 ? String(nodePages.length) : undefined,
|
||||
children
|
||||
};
|
||||
};
|
||||
|
||||
// L'arbre démarre aux dossiers racine (parentId nul ou vide).
|
||||
const rootFolders = childrenByParent.get('__root__') ?? [];
|
||||
const treeItems: TreeItem[] = rootFolders.map(buildFolderItem);
|
||||
|
||||
const globalItems: GlobalItem[] = allLores.map(l => ({
|
||||
id: l.id!, name: l.name, route: `/lore/${l.id}`
|
||||
}));
|
||||
|
||||
const templatesPanel: BottomPanel = {
|
||||
id: 'templates',
|
||||
title: 'Templates',
|
||||
initiallyOpen: true,
|
||||
items: [
|
||||
...templates.map(t => ({
|
||||
id: t.id!,
|
||||
label: t.name,
|
||||
meta: `${t.fieldCount ?? t.fields.length} champs`,
|
||||
route: `/lore/${lore.id}/templates/${t.id}`
|
||||
})),
|
||||
{
|
||||
id: 'create-template',
|
||||
label: '+ Nouveau template',
|
||||
isAction: true,
|
||||
route: `/lore/${lore.id}/templates/create`
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return {
|
||||
title: lore.name,
|
||||
items: treeItems,
|
||||
createActions: [
|
||||
{ id: 'create-node', label: '+ Dossier', variant: 'primary', route: `/lore/${lore.id}/nodes/create` },
|
||||
{ id: 'create-page', label: '+ Page', variant: 'secondary', route: `/lore/${lore.id}/pages/create` }
|
||||
],
|
||||
globalItems,
|
||||
globalBackLabel: 'Tous les lores',
|
||||
globalBackRoute: '/lore',
|
||||
bottomPanel: templatesPanel
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne l'ensemble des IDs de `rootId` et de tous ses descendants (sous-dossiers).
|
||||
* Utilisé pour empêcher de choisir comme parent un dossier qui serait soi-même
|
||||
* ou un de ses descendants (ce qui créerait un cycle dans l'arbre).
|
||||
*/
|
||||
export function collectDescendantIds(rootId: string, allNodes: LoreNode[]): Set<string> {
|
||||
const ids = new Set<string>([rootId]);
|
||||
let grew = true;
|
||||
while (grew) {
|
||||
grew = false;
|
||||
for (const n of allNodes) {
|
||||
if (n.parentId && ids.has(n.parentId) && !ids.has(n.id!)) {
|
||||
ids.add(n.id!);
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
42
web/src/app/lore/lore.component.html
Normal file
42
web/src/app/lore/lore.component.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<div class="lore-page">
|
||||
|
||||
<div class="lore-hero">
|
||||
<lucide-icon [img]="BookOpen" [size]="56" class="hero-icon"></lucide-icon>
|
||||
<h1>Vos Univers</h1>
|
||||
<p class="hero-subtitle">Sélectionnez un lore existant ou créez un nouvel univers</p>
|
||||
</div>
|
||||
|
||||
<div class="lore-grid">
|
||||
|
||||
<div class="lore-card" *ngFor="let lore of lores" (click)="navigateToDetail(lore.id!)">
|
||||
<div class="card-header">
|
||||
<lucide-icon [img]="Folder" [size]="20" class="card-icon"></lucide-icon>
|
||||
<span class="card-date">Il y a 2h</span>
|
||||
</div>
|
||||
<h2>{{ lore.name }}</h2>
|
||||
<p class="card-description">{{ lore.description }}</p>
|
||||
<div class="card-stats">
|
||||
<span>📄 {{ lore.pageCount || 0 }} pages</span>
|
||||
<span>🌳 {{ lore.nodeCount || 0 }} dossiers</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lore-card card-new" (click)="openCreateModal()">
|
||||
<div class="new-icon">
|
||||
<lucide-icon [img]="Plus" [size]="20"></lucide-icon>
|
||||
</div>
|
||||
<h2>Nouveau Lore</h2>
|
||||
<p class="card-description">Créez un nouvel univers</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<p class="tip">💡 Astuce : Utilisez les templates pour structurer votre univers de manière cohérente</p>
|
||||
|
||||
</div>
|
||||
|
||||
<app-lore-create
|
||||
*ngIf="showCreateModal"
|
||||
(close)="onModalClose()"
|
||||
(created)="onLoreCreated($event)">
|
||||
</app-lore-create>
|
||||
102
web/src/app/lore/lore.component.scss
Normal file
102
web/src/app/lore/lore.component.scss
Normal file
@@ -0,0 +1,102 @@
|
||||
.lore-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 3rem 2rem;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.lore-hero {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
|
||||
.hero-icon { display: block; margin-bottom: 1rem; color: #6c63ff; }
|
||||
|
||||
h1 { font-size: 2rem; font-weight: 700; color: white; margin-bottom: 0.5rem; }
|
||||
|
||||
.hero-subtitle { color: #6b7280; font-size: 0.95rem; }
|
||||
}
|
||||
|
||||
.lore-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.lore-card {
|
||||
background: #111827;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, transform 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #6c63ff;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card-icon { font-size: 1.25rem; color: #6c63ff; }
|
||||
.card-date { font-size: 0.75rem; color: #6b7280; }
|
||||
|
||||
h2 { color: white; font-size: 1.1rem; margin-bottom: 0.5rem; }
|
||||
|
||||
.card-description {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 1rem;
|
||||
// Tronque à 3 lignes avec "…" : évite qu'une description longue étire
|
||||
// la carte et par ricochet la carte "Nouveau Lore" alignée sur sa hauteur.
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
}
|
||||
|
||||
.card-new {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-style: dashed;
|
||||
border-color: #374151;
|
||||
text-align: center;
|
||||
|
||||
.new-icon {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
background: #1f2937;
|
||||
color: #6c63ff;
|
||||
font-size: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.tip {
|
||||
margin-top: 3rem;
|
||||
font-size: 0.8rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
72
web/src/app/lore/lore.component.ts
Normal file
72
web/src/app/lore/lore.component.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { LucideAngularModule, BookOpen, Folder, Plus } from 'lucide-angular';
|
||||
import { LoreService } from '../services/lore.service';
|
||||
import { Lore } from '../services/lore.model';
|
||||
import { LoreCreateComponent } from './lore-create/lore-create.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lore',
|
||||
standalone: true,
|
||||
imports: [CommonModule, LucideAngularModule, LoreCreateComponent],
|
||||
templateUrl: './lore.component.html',
|
||||
styleUrls: ['./lore.component.scss']
|
||||
})
|
||||
export class LoreComponent implements OnInit {
|
||||
lores: Lore[] = [];
|
||||
loading = true;
|
||||
error = false;
|
||||
|
||||
readonly BookOpen = BookOpen;
|
||||
readonly Folder = Folder;
|
||||
readonly Plus = Plus;
|
||||
|
||||
showCreateModal = false;
|
||||
|
||||
constructor(
|
||||
private loreService: LoreService,
|
||||
private router: Router
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadLores();
|
||||
}
|
||||
|
||||
loadLores(): void {
|
||||
this.loreService.getAllLores().subscribe({
|
||||
next: (data) => {
|
||||
this.lores = data;
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.error = true;
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openCreateModal(): void {
|
||||
this.showCreateModal = true;
|
||||
}
|
||||
|
||||
onModalClose(): void {
|
||||
this.showCreateModal = false;
|
||||
}
|
||||
|
||||
onLoreCreated(data: { name: string; description: string }): void {
|
||||
this.loreService.createLore(data).subscribe({
|
||||
next: () => {
|
||||
this.showCreateModal = false;
|
||||
this.loadLores();
|
||||
},
|
||||
error: () => {
|
||||
console.error('Erreur lors de la création du Lore');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
navigateToDetail(id: string): void {
|
||||
this.router.navigate(['/lore', id]);
|
||||
}
|
||||
}
|
||||
88
web/src/app/lore/page-create/page-create.component.html
Normal file
88
web/src/app/lore/page-create/page-create.component.html
Normal file
@@ -0,0 +1,88 @@
|
||||
<div class="page">
|
||||
|
||||
<header class="page-header">
|
||||
<h1>Créer une nouvelle Page</h1>
|
||||
<p class="subtitle">Créez une page à partir d'un template existant</p>
|
||||
</header>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="page-form">
|
||||
|
||||
<!-- Titre -->
|
||||
<div class="field">
|
||||
<label>Titre de la page *</label>
|
||||
<input type="text" formControlName="title" placeholder="Ex: Maître Eldrin, La Cité d'Argent..." />
|
||||
</div>
|
||||
|
||||
<!-- Template -->
|
||||
<div class="field">
|
||||
<label>Template *</label>
|
||||
|
||||
<div class="templates-grid" *ngIf="templates.length; else emptyTemplates">
|
||||
<button
|
||||
type="button"
|
||||
class="template-card"
|
||||
*ngFor="let t of templates"
|
||||
[class.selected]="selectedTemplateId === t.id"
|
||||
(click)="selectTemplate(t)">
|
||||
<div class="template-card-head">
|
||||
<lucide-icon [img]="FileText" [size]="16"></lucide-icon>
|
||||
<span class="template-name">{{ t.name }}</span>
|
||||
</div>
|
||||
<p class="template-description">{{ t.description || '—' }}</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ng-template #emptyTemplates>
|
||||
<p class="empty-hint">
|
||||
Aucun template défini pour ce Lore.
|
||||
<a [routerLink]="['/lore', loreId, 'templates', 'create']">Créer un template</a> d'abord.
|
||||
</p>
|
||||
</ng-template>
|
||||
</div>
|
||||
|
||||
<!-- Dossier de destination -->
|
||||
<div class="field">
|
||||
<label>Dossier de destination *</label>
|
||||
<select formControlName="nodeId" [attr.disabled]="preselectedNodeId ? true : null">
|
||||
<option value="" disabled>Sélectionnez un dossier</option>
|
||||
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option>
|
||||
</select>
|
||||
<p class="hint">La page sera créée dans ce dossier</p>
|
||||
</div>
|
||||
|
||||
<!-- Aide contextuelle -->
|
||||
<div class="info-box">
|
||||
💡 Option 1 : <strong>Créer la page</strong> vide, puis remplir les champs manuellement.<br>
|
||||
💡 Option 2 : <strong>Créer avec l'IA</strong> pour dialoguer avec un assistant qui pré-remplira les champs.
|
||||
</div>
|
||||
|
||||
<!-- Erreur wizard (parsing <values> ou échec HTTP) -->
|
||||
<div class="wizard-error" *ngIf="wizardError" role="alert">{{ wizardError }}</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="actions-row">
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
||||
<button type="button" class="btn-ai" (click)="openWizard()" [disabled]="!canSubmit"
|
||||
title="Ouvrir l'assistant IA pour pré-remplir les champs">
|
||||
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||
Créer avec l'IA
|
||||
</button>
|
||||
<button type="submit" class="btn-primary" [disabled]="!canSubmit">Créer la page</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Drawer chat IA en mode wizard -->
|
||||
<app-ai-chat-drawer
|
||||
[loreId]="loreId"
|
||||
[isOpen]="chatOpen"
|
||||
[welcomeMessage]="wizardWelcome"
|
||||
[systemPromptAddon]="wizardSystemPrompt"
|
||||
[quickSuggestions]="wizardSuggestions"
|
||||
[primaryAction]="wizardPrimaryAction"
|
||||
(close)="closeWizard()"
|
||||
(assistantReply)="onWizardReply($event)"
|
||||
(primaryActionClick)="applyWizardAndCreate()">
|
||||
</app-ai-chat-drawer>
|
||||
186
web/src/app/lore/page-create/page-create.component.scss
Normal file
186
web/src/app/lore/page-create/page-create.component.scss
Normal file
@@ -0,0 +1,186 @@
|
||||
.page {
|
||||
padding: 2rem 3rem;
|
||||
max-width: 860px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
.subtitle {
|
||||
color: #9ca3af;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.page-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
input, select {
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
color: white;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #6c63ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.76rem;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
color: #9ca3af;
|
||||
font-size: 0.88rem;
|
||||
|
||||
a { color: #a5b4fc; text-decoration: underline; }
|
||||
}
|
||||
|
||||
.templates-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 0.9rem 1rem;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
color: #d1d5db;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
|
||||
&:hover { border-color: #3a3a55; background: #20203a; }
|
||||
|
||||
&.selected {
|
||||
border-color: #6c63ff;
|
||||
background: #1e1c3a;
|
||||
}
|
||||
|
||||
.template-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
.template-name {
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
.template-description {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
color: #9ca3af;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
border-left: 3px solid #6c63ff;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
color: #d1d5db;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-primary, .btn-secondary {
|
||||
padding: 0.65rem 1.2rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
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-ai {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.65rem 1.1rem;
|
||||
background: transparent;
|
||||
color: #a5b4fc;
|
||||
border: 1px solid #6c63ff;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
|
||||
&:hover:not(:disabled) { background: #1f1d3a; }
|
||||
&:disabled { opacity: 0.4; cursor: not-allowed; border-color: #2a2a3d; color: #6b7280; }
|
||||
}
|
||||
|
||||
.wizard-error {
|
||||
background: #3f1f1f;
|
||||
color: #fca5a5;
|
||||
border: 1px solid #7f1d1d;
|
||||
padding: 0.65rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
253
web/src/app/lore/page-create/page-create.component.ts
Normal file
253
web/src/app/lore/page-create/page-create.component.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { LucideAngularModule, FileText, Sparkles } 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 { Template } from '../../services/template.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
import { AiChatDrawerComponent, ChatPrimaryAction } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
|
||||
/**
|
||||
* Écran de création d'une Page.
|
||||
*
|
||||
* Deux entrées possibles :
|
||||
* - /lore/:loreId/pages/create → noeud choisi depuis le template
|
||||
* - /lore/:loreId/nodes/:nodeId/pages/create → noeud pré-rempli depuis l'URL
|
||||
*
|
||||
* Le MVP est volontairement simple (maquette "création de page") : titre +
|
||||
* choix de template (grille) + noeud de destination. L'édition détaillée des
|
||||
* champs dynamiques du template se fait APRÈS création, via l'écran page-edit.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-page-create',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, RouterModule, LucideAngularModule, AiChatDrawerComponent],
|
||||
templateUrl: './page-create.component.html',
|
||||
styleUrls: ['./page-create.component.scss']
|
||||
})
|
||||
export class PageCreateComponent implements OnInit, OnDestroy {
|
||||
readonly FileText = FileText;
|
||||
readonly Sparkles = Sparkles;
|
||||
|
||||
form: FormGroup;
|
||||
loreId = '';
|
||||
/** Pré-rempli si la route contient :nodeId. */
|
||||
preselectedNodeId: string | null = null;
|
||||
nodes: LoreNode[] = [];
|
||||
templates: Template[] = [];
|
||||
/** Template actuellement sélectionné dans la grille. */
|
||||
selectedTemplateId: string | null = null;
|
||||
|
||||
// --- Mode wizard IA (étape b6) -----------------------------------------
|
||||
|
||||
/** Drawer chat ouvert ? */
|
||||
chatOpen = false;
|
||||
/** Dernière réponse complète de l'assistant — on y cherchera le bloc <values>. */
|
||||
private lastWizardReply: string | null = null;
|
||||
/** Erreur de parsing du bloc <values> — affichée sous le drawer. */
|
||||
wizardError: string | null = null;
|
||||
/** Action primaire du wizard : applique les valeurs extraites et crée la page. */
|
||||
readonly wizardPrimaryAction: ChatPrimaryAction = { label: 'Appliquer et créer la page' };
|
||||
/** Suggestions rapides orientées "affiner le résultat" (mode wizard). */
|
||||
readonly wizardSuggestions: string[] = [
|
||||
'Rends la description plus courte',
|
||||
'Ajoute un trait distinctif marquant',
|
||||
'Donne un ton plus sombre'
|
||||
];
|
||||
|
||||
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({
|
||||
title: ['', Validators.required],
|
||||
nodeId: ['', Validators.required]
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.pageTitleService.set('Nouvelle page');
|
||||
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
|
||||
this.preselectedNodeId = this.route.snapshot.paramMap.get('nodeId');
|
||||
|
||||
loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService)
|
||||
.subscribe(data => {
|
||||
this.nodes = data.nodes;
|
||||
this.templates = data.templates;
|
||||
this.layoutService.show(buildLoreSidebarConfig(data));
|
||||
|
||||
// Si nodeId fourni par l'URL, on verrouille la valeur du formulaire.
|
||||
if (this.preselectedNodeId) {
|
||||
this.form.patchValue({ nodeId: this.preselectedNodeId });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
selectTemplate(template: Template): void {
|
||||
this.selectedTemplateId = template.id!;
|
||||
// Si pas de noeud pré-choisi par l'URL, on pré-remplit avec le defaultNodeId du template.
|
||||
if (!this.preselectedNodeId && template.defaultNodeId) {
|
||||
this.form.patchValue({ nodeId: template.defaultNodeId });
|
||||
}
|
||||
}
|
||||
|
||||
get canSubmit(): boolean {
|
||||
return this.form.valid && !!this.selectedTemplateId;
|
||||
}
|
||||
|
||||
get selectedTemplate(): Template | null {
|
||||
return this.templates.find(t => t.id === this.selectedTemplateId) ?? null;
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (!this.canSubmit) return;
|
||||
const raw = this.form.value;
|
||||
this.pageService.create({
|
||||
loreId: this.loreId,
|
||||
nodeId: raw.nodeId,
|
||||
templateId: this.selectedTemplateId!,
|
||||
title: raw.title
|
||||
}).subscribe({
|
||||
// Après la création classique, la coquille est vide → on redirige
|
||||
// vers l'écran d'édition pour que l'utilisateur remplisse les champs
|
||||
// dynamiques du template.
|
||||
next: created => this.router.navigate(['/lore', this.loreId, 'pages', created.id, 'edit']),
|
||||
error: () => console.error('Erreur lors de la création de la page')
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.router.navigate(['/lore', this.loreId]);
|
||||
}
|
||||
|
||||
// --- Mode wizard IA (étape b6) -----------------------------------------
|
||||
|
||||
openWizard(): void {
|
||||
if (!this.canSubmit) return;
|
||||
this.wizardError = null;
|
||||
this.lastWizardReply = null;
|
||||
this.chatOpen = true;
|
||||
}
|
||||
|
||||
closeWizard(): void {
|
||||
this.chatOpen = false;
|
||||
}
|
||||
|
||||
/** Mémorise la dernière réponse de l'assistant — on y cherchera le bloc <values>. */
|
||||
onWizardReply(reply: string): void {
|
||||
this.lastWizardReply = reply;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clic sur "Appliquer et créer la page" :
|
||||
* 1. Extraire le bloc JSON <values>...</values> de la dernière réponse.
|
||||
* 2. Créer la page avec titre + template + nodeId + values.
|
||||
* 3. Naviguer vers l'édition pour que l'utilisateur finalise.
|
||||
*/
|
||||
applyWizardAndCreate(): void {
|
||||
if (!this.canSubmit || !this.lastWizardReply) {
|
||||
this.wizardError = "L'assistant n'a pas encore répondu. Décrivez d'abord votre idée.";
|
||||
return;
|
||||
}
|
||||
const values = this.extractValuesBlock(this.lastWizardReply);
|
||||
if (!values) {
|
||||
this.wizardError = "Impossible d'extraire les valeurs. Demandez à l'assistant de proposer à nouveau.";
|
||||
return;
|
||||
}
|
||||
this.wizardError = null;
|
||||
const raw = this.form.value;
|
||||
// Le backend POST /api/pages ne prend pas `values` — on crée d'abord la
|
||||
// coquille, puis on PUT immédiatement avec les valeurs extraites.
|
||||
// 2 roundtrips, mais zéro modification backend nécessaire.
|
||||
this.pageService.create({
|
||||
loreId: this.loreId,
|
||||
nodeId: raw.nodeId,
|
||||
templateId: this.selectedTemplateId!,
|
||||
title: raw.title
|
||||
}).subscribe({
|
||||
next: (created) => {
|
||||
const updated = { ...created, values };
|
||||
this.pageService.update(created.id!, updated).subscribe({
|
||||
next: () => this.router.navigate(['/lore', this.loreId, 'pages', created.id]),
|
||||
error: () => this.wizardError = 'Page créée, mais impossible d\'appliquer les valeurs.'
|
||||
});
|
||||
},
|
||||
error: () => this.wizardError = 'Erreur lors de la création de la page.'
|
||||
});
|
||||
}
|
||||
|
||||
/** Prompt système injecté dans le backend pour le mode wizard. */
|
||||
get wizardSystemPrompt(): string | null {
|
||||
const tpl = this.selectedTemplate;
|
||||
if (!tpl || !this.canSubmit) return null;
|
||||
const title = this.form.value.title as string;
|
||||
// Seuls les champs TEXT sont proposes a l'IA : l'IA ne genere pas d'images.
|
||||
const textFields = (tpl.fields ?? []).filter(f => f.type === 'TEXT');
|
||||
const fieldsList = textFields.length ? textFields.map(f => `"${f.name}"`).join(', ') : '(aucun champ)';
|
||||
const exampleJson = textFields.length
|
||||
? '{\n ' + textFields.map(f => `"${f.name}": "valeur proposée"`).join(',\n ') + '\n}'
|
||||
: '{}';
|
||||
|
||||
return `MODE WIZARD — CRÉATION DE PAGE
|
||||
|
||||
L'utilisateur crée une nouvelle page intitulée "${title}" à partir du template "${tpl.name}".
|
||||
Les champs à proposer sont : ${fieldsList}.
|
||||
|
||||
Règles de cohérence :
|
||||
- Tu PEUX inventer des éléments originaux (personnages, lieux, objets, intrigues) — c'est ton rôle.
|
||||
- Tu ne peux PAS faire référence à un élément comme s'il existait déjà dans l'univers, sauf s'il apparaît EXACTEMENT dans la carte du Lore fournie plus haut.
|
||||
- Si l'utilisateur évoque un élément absent de la carte, suggère de le créer plutôt que d'inventer des détails fictifs à son sujet.
|
||||
|
||||
Format de réponse OBLIGATOIRE :
|
||||
Après avoir dialogué (1-3 phrases), termine CHAQUE réponse par un bloc JSON entre balises <values>, sans rien ajouter après :
|
||||
|
||||
<values>
|
||||
${exampleJson}
|
||||
</values>
|
||||
|
||||
Les clés du JSON doivent correspondre EXACTEMENT aux noms de champs indiqués. Laisse "" si tu manques d'info pour un champ.`;
|
||||
}
|
||||
|
||||
/** Welcome message contextualisé au template choisi. */
|
||||
get wizardWelcome(): string {
|
||||
const tpl = this.selectedTemplate;
|
||||
if (!tpl) return 'Décrivez ce que vous souhaitez créer.';
|
||||
return `Super, on va créer une page "${tpl.name}" ! Décrivez-la-moi en quelques mots — contexte, rôle, traits marquants — et je proposerai des valeurs pour chaque champ.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait le bloc <values>{...}</values> de la réponse assistant et parse en objet.
|
||||
* Retourne null si absent ou JSON invalide.
|
||||
*/
|
||||
private extractValuesBlock(reply: string): Record<string, string> | null {
|
||||
const match = reply.match(/<values>\s*([\s\S]*?)\s*<\/values>/i);
|
||||
if (!match) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(match[1]) as Record<string, unknown>;
|
||||
// On coerce toute valeur non-string en string (l'IA peut parfois produire des nombres).
|
||||
const result: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
result[k] = v == null ? '' : String(v);
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
}
|
||||
}
|
||||
123
web/src/app/lore/page-edit/page-edit.component.html
Normal file
123
web/src/app/lore/page-edit/page-edit.component.html
Normal file
@@ -0,0 +1,123 @@
|
||||
<div class="page" *ngIf="page">
|
||||
|
||||
<app-breadcrumb [items]="breadcrumbItems"></app-breadcrumb>
|
||||
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>{{ page.title }}</h1>
|
||||
<p class="subtitle">{{ template?.name || 'Page' }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-ai"
|
||||
(click)="toggleChat()"
|
||||
[disabled]="aiLoading"
|
||||
[class.active]="chatOpen"
|
||||
title="Ouvrir l'Assistant IA (chat ou remplissage automatique)">
|
||||
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||
{{ aiLoading ? 'Génération…' : 'Assistant IA' }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" [routerLink]="['/lore', loreId, 'pages', pageId]">Annuler</button>
|
||||
<button type="button" class="btn-danger" (click)="delete()">Supprimer</button>
|
||||
<button type="button" class="btn-primary" (click)="save()" [disabled]="!title.trim()">
|
||||
Sauvegarder
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div *ngIf="aiError" class="ai-error-banner" role="alert">
|
||||
<span>{{ aiError }}</span>
|
||||
<button type="button" class="ai-error-dismiss" (click)="aiError = null" aria-label="Fermer">×</button>
|
||||
</div>
|
||||
|
||||
<form class="edit-form">
|
||||
|
||||
<!-- Identité ----------------------------------------------------- -->
|
||||
<div class="field">
|
||||
<label>Nom</label>
|
||||
<input type="text" [(ngModel)]="title" name="title" required />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Dossier</label>
|
||||
<select [(ngModel)]="nodeId" name="nodeId">
|
||||
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option>
|
||||
</select>
|
||||
<p class="hint">Déplacez cette page dans un autre dossier</p>
|
||||
</div>
|
||||
|
||||
<!-- Champs dynamiques du template -------------------------------- -->
|
||||
<ng-container *ngIf="template?.fields?.length">
|
||||
<h2 class="section-title">Champs</h2>
|
||||
<ng-container *ngFor="let field of template!.fields">
|
||||
<!-- Champ TEXT : textarea editable -->
|
||||
<div class="field" *ngIf="field.type === 'TEXT'">
|
||||
<label>{{ field.name }}</label>
|
||||
<textarea
|
||||
[(ngModel)]="values[field.name]"
|
||||
[name]="'value_' + field.name"
|
||||
rows="4"
|
||||
[placeholder]="'Valeur pour ' + field.name + '...'">
|
||||
</textarea>
|
||||
</div>
|
||||
<!-- Champ IMAGE : galerie editable. -->
|
||||
<div class="field" *ngIf="field.type === 'IMAGE'">
|
||||
<label>{{ field.name }}</label>
|
||||
<app-image-gallery
|
||||
[imageIds]="imageValues[field.name] || []"
|
||||
[editable]="true"
|
||||
(imageIdsChange)="imageValues[field.name] = $event">
|
||||
</app-image-gallery>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<!-- Tags --------------------------------------------------------- -->
|
||||
<h2 class="section-title">Tags</h2>
|
||||
<div class="field">
|
||||
<app-chips-input
|
||||
[value]="tags"
|
||||
(valueChange)="tags = $event"
|
||||
placeholder="Ajouter un tag (Entrée pour valider)...">
|
||||
</app-chips-input>
|
||||
<p class="hint">Mots-clés libres pour classer et retrouver cette page</p>
|
||||
</div>
|
||||
|
||||
<!-- Liens vers d'autres pages ----------------------------------- -->
|
||||
<h2 class="section-title">Pages liées</h2>
|
||||
<div class="field">
|
||||
<app-lore-link-picker
|
||||
[value]="relatedPageIds"
|
||||
[availablePages]="allPages"
|
||||
[excludePageId]="pageId"
|
||||
[loreId]="loreId"
|
||||
(valueChange)="relatedPageIds = $event">
|
||||
</app-lore-link-picker>
|
||||
<p class="hint">Cliquez sur un lien pour ouvrir la page associée</p>
|
||||
</div>
|
||||
|
||||
<!-- Notes privées ----------------------------------------------- -->
|
||||
<h2 class="section-title">Notes privées</h2>
|
||||
<div class="field">
|
||||
<textarea
|
||||
[(ngModel)]="notes"
|
||||
name="notes"
|
||||
rows="4"
|
||||
placeholder="Notes personnelles (non exportées vers FoundryVTT)">
|
||||
</textarea>
|
||||
<p class="hint">Visibles uniquement par vous. Utiles pour préparer vos sessions.</p>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Drawer chat IA (hors du .page pour pouvoir couvrir le viewport côté droit) -->
|
||||
<app-ai-chat-drawer
|
||||
[loreId]="loreId"
|
||||
[pageId]="pageId"
|
||||
[isOpen]="chatOpen"
|
||||
[quickSuggestions]="chatQuickSuggestions"
|
||||
[primaryAction]="chatPrimaryAction"
|
||||
(close)="chatOpen = false"
|
||||
(primaryActionClick)="onChatFillRequested()">
|
||||
</app-ai-chat-drawer>
|
||||
153
web/src/app/lore/page-edit/page-edit.component.scss
Normal file
153
web/src/app/lore/page-edit/page-edit.component.scss
Normal file
@@ -0,0 +1,153 @@
|
||||
.page {
|
||||
padding: 2rem 3rem;
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
margin: 1.5rem 0 0.25rem;
|
||||
border-top: 1px solid #1e1e3a;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
color: white;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #6c63ff;
|
||||
}
|
||||
}
|
||||
|
||||
textarea { resize: vertical; }
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.76rem;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-primary, .btn-secondary, .btn-danger, .btn-ai {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
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 { background: #5a2a2a; }
|
||||
}
|
||||
|
||||
.btn-ai {
|
||||
background: transparent;
|
||||
color: #a5b4fc;
|
||||
border: 1px solid #2a2a3d;
|
||||
|
||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
&:hover:not(:disabled) { background: #1f2937; }
|
||||
&.active {
|
||||
background: #1f2937;
|
||||
border-color: #6c63ff;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-error-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
background: #3f1f1f;
|
||||
color: #fca5a5;
|
||||
border: 1px solid #7f1d1d;
|
||||
border-radius: 6px;
|
||||
padding: 0.7rem 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
font-size: 0.88rem;
|
||||
|
||||
.ai-error-dismiss {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #fca5a5;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 0.25rem;
|
||||
|
||||
&:hover { color: white; }
|
||||
}
|
||||
}
|
||||
271
web/src/app/lore/page-edit/page-edit.component.ts
Normal file
271
web/src/app/lore/page-edit/page-edit.component.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule, Sparkles } 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 { Template } from '../../services/template.model';
|
||||
import { Page } from '../../services/page.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
import { ChipsInputComponent } from '../../shared/chips-input/chips-input.component';
|
||||
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
|
||||
import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.component';
|
||||
import { AiChatDrawerComponent, ChatPrimaryAction } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
||||
import { Lore } from '../../services/lore.model';
|
||||
|
||||
/**
|
||||
* Écran d'édition d'une Page.
|
||||
*
|
||||
* Fonctionnalités actuelles (Phase 5A + 5B) :
|
||||
* - Titre (modifiable) + Dossier (déplaçable)
|
||||
* - Champs dynamiques du Template (un textarea par champ, valeurs stockées dans `values`)
|
||||
* - Tags (chips) — Phase 5B
|
||||
* - Liens vers d'autres pages (autocomplete) — Phase 5B
|
||||
* - Notes privées MJ
|
||||
*
|
||||
* À venir (Phase 5D) :
|
||||
* - Bouton "Assistant IA" branché (Phase 3 Python)
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-page-edit',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, RouterLink, LucideAngularModule, ChipsInputComponent, LoreLinkPickerComponent, BreadcrumbComponent, AiChatDrawerComponent, ImageGalleryComponent],
|
||||
templateUrl: './page-edit.component.html',
|
||||
styleUrls: ['./page-edit.component.scss']
|
||||
})
|
||||
export class PageEditComponent implements OnInit, OnDestroy {
|
||||
readonly Sparkles = Sparkles;
|
||||
|
||||
loreId = '';
|
||||
pageId = '';
|
||||
lore: Lore | null = null;
|
||||
page: Page | null = null;
|
||||
template: Template | null = null;
|
||||
nodes: LoreNode[] = [];
|
||||
/** Toutes les pages du lore — nécessaire au lore-link-picker pour l'autocomplete. */
|
||||
allPages: Page[] = [];
|
||||
|
||||
/** Modèle du formulaire (bindé via ngModel). */
|
||||
title = '';
|
||||
nodeId = '';
|
||||
notes = '';
|
||||
/** Valeurs des champs dynamiques TEXT, indexées par fieldName. */
|
||||
values: Record<string, string> = {};
|
||||
/**
|
||||
* Valeurs des champs dynamiques IMAGE : pour chaque nom de champ IMAGE,
|
||||
* la liste ordonnee des IDs d'images uploadees.
|
||||
*/
|
||||
imageValues: Record<string, string[]> = {};
|
||||
/** Étiquettes libres (Phase 5B). */
|
||||
tags: string[] = [];
|
||||
/** IDs des pages liées (Phase 5B). */
|
||||
relatedPageIds: string[] = [];
|
||||
|
||||
/** Phase 5D — état de l'Assistant IA (one-shot). */
|
||||
aiLoading = false;
|
||||
aiError: string | null = null;
|
||||
|
||||
/** Phase b5 — drawer chat IA (conversationnel). */
|
||||
chatOpen = false;
|
||||
/** Action primaire dans le chat : déclenche le one-shot b4 (remplissage automatique). */
|
||||
readonly chatPrimaryAction: ChatPrimaryAction = { label: 'Remplir automatiquement tous les champs' };
|
||||
/** Suggestions rapides hardcodées (MVP). */
|
||||
readonly chatQuickSuggestions: string[] = [
|
||||
"Étoffe l'histoire de cette page",
|
||||
'Suggère des liens avec d\'autres pages du Lore',
|
||||
'Propose une intrigue secondaire'
|
||||
];
|
||||
|
||||
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')!;
|
||||
|
||||
// S'abonner à paramMap plutôt que de lire snapshot une fois : sinon, quand on
|
||||
// navigue d'une page à une autre (ex. via les chips du lore-link-picker),
|
||||
// Angular réutilise le composant et ngOnInit ne se relance pas → l'écran
|
||||
// resterait figé sur l'ancienne page.
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
const newPageId = pm.get('pageId')!;
|
||||
if (newPageId && newPageId !== this.pageId) {
|
||||
this.pageId = newPageId;
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
forkJoin({
|
||||
sidebar: loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService),
|
||||
page: this.pageService.getById(this.pageId)
|
||||
}).subscribe(({ sidebar, page }) => {
|
||||
this.lore = sidebar.lore;
|
||||
this.nodes = sidebar.nodes;
|
||||
this.allPages = sidebar.pages;
|
||||
this.layoutService.show(buildLoreSidebarConfig(sidebar));
|
||||
this.hydrate(page, sidebar.templates);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le fil d'Ariane : Lore > [dossiers parents...] > Dossier courant > Page.
|
||||
* Les items sont cliquables sauf le dernier (position courante).
|
||||
* On remonte la hiérarchie via `parentId` jusqu'à la racine, puis on inverse.
|
||||
*/
|
||||
get breadcrumbItems(): BreadcrumbItem[] {
|
||||
if (!this.lore || !this.page) return [];
|
||||
|
||||
const items: BreadcrumbItem[] = [
|
||||
{ label: this.lore.name, route: ['/lore', this.loreId] }
|
||||
];
|
||||
|
||||
// Chemin des dossiers (racine → dossier courant) via remontée parentId.
|
||||
const folderChain: LoreNode[] = [];
|
||||
let currentNode = this.nodes.find(n => n.id === this.nodeId);
|
||||
while (currentNode) {
|
||||
folderChain.unshift(currentNode);
|
||||
currentNode = currentNode.parentId
|
||||
? this.nodes.find(n => n.id === currentNode!.parentId)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
for (const node of folderChain) {
|
||||
items.push({
|
||||
label: node.name,
|
||||
route: ['/lore', this.loreId, 'folders', node.id, 'edit']
|
||||
});
|
||||
}
|
||||
|
||||
// Position courante : la page (non-cliquable).
|
||||
items.push({ label: this.title || this.page.title });
|
||||
return items;
|
||||
}
|
||||
|
||||
private hydrate(page: Page, templates: Template[]): void {
|
||||
this.page = page;
|
||||
this.template = templates.find(t => t.id === page.templateId) ?? null;
|
||||
this.title = page.title;
|
||||
this.nodeId = page.nodeId;
|
||||
this.notes = page.notes ?? '';
|
||||
// On initialise une entrée pour chaque field TEXT du template, même vide,
|
||||
// pour que le formulaire ait toujours les champs attendus.
|
||||
// Les champs IMAGE ne sont pas geres dans `values` (ils auront leur propre
|
||||
// structure `imageValues: Map<String, List<String>>` a l'etape 5).
|
||||
const base: Record<string, string> = {};
|
||||
const imageBase: Record<string, string[]> = {};
|
||||
for (const f of this.template?.fields ?? []) {
|
||||
if (f.type === 'TEXT') {
|
||||
base[f.name] = page.values?.[f.name] ?? '';
|
||||
} else if (f.type === 'IMAGE') {
|
||||
// Initialise la galerie d'images pour ce champ (vide si jamais rempli).
|
||||
imageBase[f.name] = [...(page.imageValues?.[f.name] ?? [])];
|
||||
}
|
||||
}
|
||||
this.values = base;
|
||||
this.imageValues = imageBase;
|
||||
this.tags = [...(page.tags ?? [])];
|
||||
this.relatedPageIds = [...(page.relatedPageIds ?? [])];
|
||||
this.pageTitleService.set(page.title);
|
||||
}
|
||||
|
||||
save(): void {
|
||||
if (!this.page || !this.title.trim()) return;
|
||||
const updated: Page = {
|
||||
...this.page,
|
||||
title: this.title,
|
||||
nodeId: this.nodeId,
|
||||
notes: this.notes,
|
||||
values: this.values,
|
||||
imageValues: this.imageValues,
|
||||
tags: this.tags,
|
||||
relatedPageIds: this.relatedPageIds
|
||||
};
|
||||
this.pageService.update(this.pageId, updated).subscribe({
|
||||
next: () => this.router.navigate(['/lore', this.loreId, 'pages', this.pageId]),
|
||||
error: () => console.error('Erreur lors de la sauvegarde de la page')
|
||||
});
|
||||
}
|
||||
|
||||
// --- Chat IA conversationnel (Phase b5) --------------------------------
|
||||
|
||||
toggleChat(): void {
|
||||
this.chatOpen = !this.chatOpen;
|
||||
}
|
||||
|
||||
/** Appelé depuis le drawer quand l'utilisateur clique sur l'action primaire. */
|
||||
onChatFillRequested(): void {
|
||||
this.chatOpen = false; // on ferme le drawer : le résultat apparaîtra dans les textareas
|
||||
this.runAssistantAI();
|
||||
}
|
||||
|
||||
/**
|
||||
* Assistant IA (Phase 5D) — demande au Brain des suggestions de valeurs
|
||||
* pour les champs dynamiques du template.
|
||||
*
|
||||
* Merge soft : on n'écrase pas une valeur déjà saisie par l'utilisateur
|
||||
* si la suggestion est vide. L'utilisateur garde le contrôle final avant
|
||||
* de cliquer "Sauvegarder".
|
||||
*/
|
||||
runAssistantAI(): void {
|
||||
if (this.aiLoading || !this.template?.fields?.length) return;
|
||||
this.aiLoading = true;
|
||||
this.aiError = null;
|
||||
this.pageService.generateValues(this.pageId).subscribe({
|
||||
next: (suggestions) => {
|
||||
this.mergeSuggestions(suggestions);
|
||||
this.aiLoading = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.aiLoading = false;
|
||||
this.aiError = err?.status === 502
|
||||
? "L'assistant IA est injoignable. V\u00e9rifiez que le service Brain tourne."
|
||||
: "\u00c9chec de la g\u00e9n\u00e9ration IA. R\u00e9essayez dans un instant.";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fusionne les suggestions dans les valeurs courantes.
|
||||
* Merge soft :
|
||||
* - Suggestion non-vide → on applique (l'utilisateur a demandé la génération).
|
||||
* - Suggestion vide → on NE touche PAS à la valeur courante (l'IA n'a rien à proposer pour ce champ).
|
||||
*/
|
||||
private mergeSuggestions(suggestions: Record<string, string>): void {
|
||||
// L'IA ne genere que des valeurs texte : on ignore les champs IMAGE.
|
||||
for (const field of this.template?.fields ?? []) {
|
||||
if (field.type !== 'TEXT') continue;
|
||||
const suggestion = suggestions[field.name];
|
||||
if (suggestion && suggestion.trim()) {
|
||||
this.values[field.name] = suggestion;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delete(): void {
|
||||
if (!this.page) return;
|
||||
if (!confirm(`Supprimer la page "${this.page.title}" ?`)) return;
|
||||
this.pageService.delete(this.pageId).subscribe({
|
||||
next: () => this.router.navigate(['/lore', this.loreId]),
|
||||
error: () => console.error('Erreur lors de la suppression de la page')
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
}
|
||||
}
|
||||
65
web/src/app/lore/page-view/page-view.component.html
Normal file
65
web/src/app/lore/page-view/page-view.component.html
Normal file
@@ -0,0 +1,65 @@
|
||||
<div class="view-page" *ngIf="page">
|
||||
|
||||
<app-breadcrumb [items]="breadcrumbItems"></app-breadcrumb>
|
||||
|
||||
<header class="view-header">
|
||||
<div>
|
||||
<h1>{{ page.title }}</h1>
|
||||
<p class="view-subtitle">{{ template?.name || 'Page' }}</p>
|
||||
</div>
|
||||
<div class="view-actions">
|
||||
<button type="button" class="btn-primary" (click)="editMode()">
|
||||
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
||||
Modifier
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Champs dynamiques du template (seuls les champs TEXT sont rendus ici ;
|
||||
le support complet des champs IMAGE arrive a l'etape 5). -->
|
||||
<ng-container *ngIf="template?.fields?.length">
|
||||
<ng-container *ngFor="let field of template!.fields">
|
||||
<section class="view-section" *ngIf="field.type === 'TEXT'">
|
||||
<h2 class="view-section-title">{{ field.name }}</h2>
|
||||
<p class="view-section-body" *ngIf="valueOf(field.name); else emptyField">{{ valueOf(field.name) }}</p>
|
||||
<ng-template #emptyField>
|
||||
<p class="view-section-empty">Non renseigné</p>
|
||||
</ng-template>
|
||||
</section>
|
||||
<section class="view-section" *ngIf="field.type === 'IMAGE'">
|
||||
<h2 class="view-section-title">{{ field.name }}</h2>
|
||||
<app-image-gallery [imageIds]="imageIdsOf(field.name)"></app-image-gallery>
|
||||
</section>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<!-- Tags -->
|
||||
<section class="view-section" *ngIf="(page.tags?.length ?? 0) > 0">
|
||||
<h2 class="view-section-title">Tags</h2>
|
||||
<div class="view-chips">
|
||||
<span class="view-chip view-chip--tag" *ngFor="let tag of page.tags">{{ tag }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Pages liées -->
|
||||
<section class="view-section" *ngIf="(page.relatedPageIds?.length ?? 0) > 0">
|
||||
<h2 class="view-section-title">Pages liées</h2>
|
||||
<div class="view-chips">
|
||||
<a class="view-chip"
|
||||
*ngFor="let relId of page.relatedPageIds"
|
||||
[routerLink]="['/lore', loreId, 'pages', relId]">
|
||||
{{ titleOfRelated(relId) }}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Notes privées MJ -->
|
||||
<section class="view-section view-section--private" *ngIf="page.notes?.trim()">
|
||||
<h2 class="view-section-title">
|
||||
<span class="view-section-icon">🔒</span>
|
||||
Notes privées
|
||||
</h2>
|
||||
<p class="view-section-body">{{ page.notes }}</p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
4
web/src/app/lore/page-view/page-view.component.scss
Normal file
4
web/src/app/lore/page-view/page-view.component.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
// Styles spécifiques à page-view.
|
||||
// Le gros du style "fiche de jeu" vient du partial global `styles/_view.scss`.
|
||||
// Aucun override nécessaire pour l'instant — ce fichier existe pour rester
|
||||
// cohérent avec la structure des autres composants (ts/html/scss).
|
||||
127
web/src/app/lore/page-view/page-view.component.ts
Normal file
127
web/src/app/lore/page-view/page-view.component.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
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 { 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 { Template } from '../../services/template.model';
|
||||
import { Page } from '../../services/page.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.component';
|
||||
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
||||
|
||||
/**
|
||||
* Écran de consultation d'une Page (mode lecture seule).
|
||||
*
|
||||
* Responsabilité : afficher une belle fiche, sans formulaire ni scrollbar interne.
|
||||
* Chaque champ du template est rendu en bloc titré dont le corps s'étend
|
||||
* verticalement selon le contenu (via CSS `white-space: pre-wrap`).
|
||||
*
|
||||
* Route : /lore/:loreId/pages/:pageId
|
||||
* Pour modifier → bouton "Modifier" qui navigue vers /lore/:loreId/pages/:pageId/edit.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-page-view',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterModule, LucideAngularModule, BreadcrumbComponent, ImageGalleryComponent],
|
||||
templateUrl: './page-view.component.html',
|
||||
styleUrls: ['./page-view.component.scss']
|
||||
})
|
||||
export class PageViewComponent implements OnInit, OnDestroy {
|
||||
readonly Pencil = Pencil;
|
||||
|
||||
loreId = '';
|
||||
pageId = '';
|
||||
lore: Lore | null = null;
|
||||
page: Page | null = null;
|
||||
template: Template | null = null;
|
||||
nodes: LoreNode[] = [];
|
||||
allPages: Page[] = [];
|
||||
|
||||
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')!;
|
||||
// Même pattern que page-edit : on s'abonne à paramMap pour gérer la
|
||||
// navigation d'une page à l'autre (Angular réutilise le composant).
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
const newPageId = pm.get('pageId')!;
|
||||
if (newPageId && newPageId !== this.pageId) {
|
||||
this.pageId = newPageId;
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
forkJoin({
|
||||
sidebar: loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService),
|
||||
page: this.pageService.getById(this.pageId)
|
||||
}).subscribe(({ sidebar, page }) => {
|
||||
this.lore = sidebar.lore;
|
||||
this.nodes = sidebar.nodes;
|
||||
this.allPages = sidebar.pages;
|
||||
this.template = sidebar.templates.find(t => t.id === page.templateId) ?? null;
|
||||
this.page = page;
|
||||
this.layoutService.show(buildLoreSidebarConfig(sidebar));
|
||||
this.pageTitleService.set(page.title);
|
||||
});
|
||||
}
|
||||
|
||||
/** Fil d'Ariane — même logique que page-edit (remontée via parentId). */
|
||||
get breadcrumbItems(): BreadcrumbItem[] {
|
||||
if (!this.lore || !this.page) return [];
|
||||
const items: BreadcrumbItem[] = [
|
||||
{ label: this.lore.name, route: ['/lore', this.loreId] }
|
||||
];
|
||||
const folderChain: LoreNode[] = [];
|
||||
let currentNode = this.nodes.find(n => n.id === this.page!.nodeId);
|
||||
while (currentNode) {
|
||||
folderChain.unshift(currentNode);
|
||||
currentNode = currentNode.parentId
|
||||
? this.nodes.find(n => n.id === currentNode!.parentId)
|
||||
: undefined;
|
||||
}
|
||||
for (const node of folderChain) {
|
||||
items.push({ label: node.name, route: ['/lore', this.loreId, 'folders', node.id, 'edit'] });
|
||||
}
|
||||
items.push({ label: this.page.title });
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Récupère la valeur d'un champ dynamique TEXT du template. */
|
||||
valueOf(fieldName: string): string {
|
||||
return this.page?.values?.[fieldName] ?? '';
|
||||
}
|
||||
|
||||
/** IDs d'images pour un champ IMAGE (liste vide si aucune). */
|
||||
imageIdsOf(fieldName: string): string[] {
|
||||
return this.page?.imageValues?.[fieldName] ?? [];
|
||||
}
|
||||
|
||||
/** Helper — résout l'ID d'une page liée en son titre (pour affichage dans les chips). */
|
||||
titleOfRelated(pageId: string): string {
|
||||
return this.allPages.find(p => p.id === pageId)?.title ?? '(page supprimée)';
|
||||
}
|
||||
|
||||
editMode(): void {
|
||||
this.router.navigate(['/lore', this.loreId, 'pages', this.pageId, 'edit']);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<div class="page">
|
||||
|
||||
<header class="page-header">
|
||||
<h1>Créer un nouveau Template</h1>
|
||||
<p class="subtitle">Définissez un gabarit personnalisé pour créer des pages cohérentes</p>
|
||||
</header>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="template-form">
|
||||
|
||||
<!-- Colonne gauche ---------------------------------------------- -->
|
||||
<div class="col-left">
|
||||
|
||||
<div class="field">
|
||||
<label>Nom du template *</label>
|
||||
<input type="text" formControlName="name" placeholder="Ex: Auberge, Artefact, Monstre..." />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Description</label>
|
||||
<textarea formControlName="description" rows="4" placeholder="À quoi sert ce template ?"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Dossier par défaut *</label>
|
||||
<select formControlName="defaultNodeId">
|
||||
<option value="" disabled>Sélectionnez un dossier</option>
|
||||
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option>
|
||||
</select>
|
||||
<p class="hint">Les pages créées avec ce template seront placées dans ce dossier</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Colonne droite --------------------------------------------- -->
|
||||
<div class="col-right">
|
||||
|
||||
<label class="section-label">Champs du template *</label>
|
||||
|
||||
<ul class="fields-list">
|
||||
<li class="field-row" *ngFor="let f of fields; let i = index">
|
||||
<span class="field-chip" [class.field-chip-image]="f.type === 'IMAGE'">
|
||||
<lucide-icon [img]="f.type === 'IMAGE' ? ImageIcon : Type" [size]="12"></lucide-icon>
|
||||
{{ f.name }}
|
||||
</span>
|
||||
<button type="button"
|
||||
class="btn-icon btn-type-toggle"
|
||||
(click)="toggleFieldType(i)"
|
||||
[attr.aria-label]="'Basculer vers ' + (f.type === 'TEXT' ? 'Image' : 'Texte')"
|
||||
[title]="f.type === 'TEXT' ? 'Transformer en champ Image' : 'Transformer en champ Texte'">
|
||||
{{ f.type === 'TEXT' ? 'Texte' : 'Image' }}
|
||||
</button>
|
||||
<button type="button" class="btn-icon" (click)="removeField(i)" aria-label="Supprimer">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="field-row add-row">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newFieldName"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
placeholder="Nom du champ..."
|
||||
(keydown.enter)="$event.preventDefault(); addField()" />
|
||||
<select
|
||||
class="type-select"
|
||||
[(ngModel)]="newFieldType"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
aria-label="Type du champ">
|
||||
<option value="TEXT">Texte</option>
|
||||
<option value="IMAGE">Image</option>
|
||||
</select>
|
||||
<button type="button" class="btn-add" (click)="addField()" title="Ajouter le champ">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="hint">Les champs Texte sont editables librement et utilisables par l'IA. Les champs Image hebergent une galerie d'illustrations.</p>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Actions ---------------------------------------------------- -->
|
||||
<div class="actions-row">
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
||||
<button type="submit" class="btn-primary" [disabled]="form.invalid">Créer le template</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
213
web/src/app/lore/template-create/template-create.component.scss
Normal file
213
web/src/app/lore/template-create/template-create.component.scss
Normal file
@@ -0,0 +1,213 @@
|
||||
.page {
|
||||
padding: 2rem 3rem;
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
.subtitle {
|
||||
color: #9ca3af;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.template-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem 2.5rem;
|
||||
|
||||
.col-left, .col-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
|
||||
label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
color: white;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #6c63ff;
|
||||
}
|
||||
}
|
||||
|
||||
textarea { resize: vertical; }
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.76rem;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: #d1d5db;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.fields-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
.field-chip {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
background: #2a5f3f;
|
||||
color: #d1fae5;
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.88rem;
|
||||
|
||||
// Couleur discriminante pour les champs IMAGE (palette indigo).
|
||||
&.field-chip-image {
|
||||
background: #312b5c;
|
||||
color: #c7b8ff;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-type-toggle {
|
||||
width: auto;
|
||||
padding: 0 0.7rem;
|
||||
background: #2a2a3d;
|
||||
color: #d1d5db;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.02em;
|
||||
|
||||
&:hover { background: #363650; color: white; }
|
||||
}
|
||||
|
||||
.type-select {
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
color: white;
|
||||
padding: 0 0.6rem;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus { outline: none; border-color: #6c63ff; }
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
color: white;
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.88rem;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #6c63ff;
|
||||
}
|
||||
}
|
||||
|
||||
&.add-row { margin-top: 0.5rem; }
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #3f1f1f;
|
||||
color: #fca5a5;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover { background: #5a2a2a; }
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #6c63ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover { background: #5a52e0; }
|
||||
}
|
||||
|
||||
.btn-primary, .btn-secondary {
|
||||
padding: 0.65rem 1.2rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
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; }
|
||||
}
|
||||
118
web/src/app/lore/template-create/template-create.component.ts
Normal file
118
web/src/app/lore/template-create/template-create.component.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon } 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 { LoreNode } from '../../services/lore.model';
|
||||
import { FieldType, TemplateField } from '../../services/template.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
|
||||
/**
|
||||
* Écran de création d'un Template (gabarit de Page).
|
||||
* - Champs principaux : nom, description, noeud par défaut.
|
||||
* - Liste dynamique de "champs du template" (ex: "Nom", "Description", "Personnalité").
|
||||
* Le user peut ajouter/retirer n'importe lequel — tous sont égaux.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-template-create',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, ReactiveFormsModule, LucideAngularModule],
|
||||
templateUrl: './template-create.component.html',
|
||||
styleUrls: ['./template-create.component.scss']
|
||||
})
|
||||
export class TemplateCreateComponent implements OnInit, OnDestroy {
|
||||
readonly Plus = Plus;
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Type = Type;
|
||||
readonly ImageIcon = ImageIcon;
|
||||
|
||||
form: FormGroup;
|
||||
loreId = '';
|
||||
nodes: LoreNode[] = [];
|
||||
/**
|
||||
* Champs dynamiques actuellement definis. Chaque champ a un type discriminant
|
||||
* (TEXT ou IMAGE) qui pilote son rendu sur les pages.
|
||||
*/
|
||||
fields: TemplateField[] = [
|
||||
{ name: 'Nom', type: 'TEXT' },
|
||||
{ name: 'Description', type: 'TEXT' }
|
||||
];
|
||||
/** Valeur courante de l'input d'ajout de champ (non binding direct pour reset facile). */
|
||||
newFieldName = '';
|
||||
/** Type choisi pour le prochain champ a ajouter. */
|
||||
newFieldType: FieldType = 'TEXT';
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private loreService: LoreService,
|
||||
private templateService: TemplateService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
description: [''],
|
||||
defaultNodeId: ['', Validators.required]
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
|
||||
loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService).subscribe(data => {
|
||||
this.nodes = data.nodes;
|
||||
this.layoutService.show(buildLoreSidebarConfig(data));
|
||||
});
|
||||
}
|
||||
|
||||
addField(): void {
|
||||
const name = this.newFieldName.trim();
|
||||
if (!name) return;
|
||||
// Unicite par nom (on ignore le type pour eviter des collisions d'affichage).
|
||||
if (this.fields.some(f => f.name === name)) return;
|
||||
this.fields = [...this.fields, { name, type: this.newFieldType }];
|
||||
this.newFieldName = '';
|
||||
// Le type reste sur la derniere valeur choisie : pratique pour enchainer
|
||||
// plusieurs champs du meme type.
|
||||
}
|
||||
|
||||
removeField(index: number): void {
|
||||
this.fields = this.fields.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
/** Bascule le type d'un champ existant (TEXT <-> IMAGE). */
|
||||
toggleFieldType(index: number): void {
|
||||
const field = this.fields[index];
|
||||
if (!field) return;
|
||||
const nextType: FieldType = field.type === 'TEXT' ? 'IMAGE' : 'TEXT';
|
||||
this.fields = this.fields.map((f, i) => i === index ? { ...f, type: nextType } : f);
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid) return;
|
||||
const raw = this.form.value;
|
||||
this.templateService.create({
|
||||
loreId: this.loreId,
|
||||
name: raw.name,
|
||||
description: raw.description,
|
||||
defaultNodeId: raw.defaultNodeId,
|
||||
fields: this.fields
|
||||
}).subscribe({
|
||||
next: () => this.router.navigate(['/lore', this.loreId]),
|
||||
error: () => console.error('Erreur lors de la création du template')
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.router.navigate(['/lore', this.loreId]);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
}
|
||||
}
|
||||
89
web/src/app/lore/template-edit/template-edit.component.html
Normal file
89
web/src/app/lore/template-edit/template-edit.component.html
Normal file
@@ -0,0 +1,89 @@
|
||||
<div class="page" *ngIf="template">
|
||||
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>{{ template.name }}</h1>
|
||||
<p class="subtitle">Template</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-danger" (click)="delete()">Supprimer</button>
|
||||
<button type="button" class="btn-primary" (click)="save()" [disabled]="form.invalid">Sauvegarder</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form [formGroup]="form" class="template-form">
|
||||
|
||||
<!-- Colonne gauche ---------------------------------------------- -->
|
||||
<div class="col-left">
|
||||
|
||||
<div class="field">
|
||||
<label>Nom</label>
|
||||
<input type="text" formControlName="name" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Dossier par défaut</label>
|
||||
<select formControlName="defaultNodeId">
|
||||
<option value="">-- Aucun --</option>
|
||||
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option>
|
||||
</select>
|
||||
<p class="hint">Les pages créées avec ce template seront placées dans ce dossier par défaut</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Description</label>
|
||||
<textarea formControlName="description" rows="6"></textarea>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Colonne droite --------------------------------------------- -->
|
||||
<div class="col-right">
|
||||
|
||||
<label class="section-label">Champs du template</label>
|
||||
|
||||
<ul class="fields-list">
|
||||
<li class="field-row" *ngFor="let f of fields; let i = index">
|
||||
<span class="field-chip" [class.field-chip-image]="f.type === 'IMAGE'">
|
||||
<lucide-icon [img]="f.type === 'IMAGE' ? ImageIcon : Type" [size]="12"></lucide-icon>
|
||||
{{ f.name }}
|
||||
</span>
|
||||
<button type="button"
|
||||
class="btn-icon-ghost btn-type-toggle"
|
||||
(click)="toggleFieldType(i)"
|
||||
[title]="f.type === 'TEXT' ? 'Transformer en champ Image' : 'Transformer en champ Texte'">
|
||||
{{ f.type === 'TEXT' ? 'Texte' : 'Image' }}
|
||||
</button>
|
||||
<button type="button" class="btn-icon-ghost" (click)="removeField(i)" aria-label="Supprimer">
|
||||
<lucide-icon [img]="X" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="field-row add-row">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newFieldName"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
placeholder="+ Ajouter un champ"
|
||||
(keydown.enter)="$event.preventDefault(); addField()" />
|
||||
<select
|
||||
class="type-select"
|
||||
[(ngModel)]="newFieldType"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
aria-label="Type du champ">
|
||||
<option value="TEXT">Texte</option>
|
||||
<option value="IMAGE">Image</option>
|
||||
</select>
|
||||
<button type="button" class="btn-add" (click)="addField()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="hint">Texte = zone editable + generable par l'IA. Image = galerie d'illustrations.</p>
|
||||
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
235
web/src/app/lore/template-edit/template-edit.component.scss
Normal file
235
web/src/app/lore/template-edit/template-edit.component.scss
Normal file
@@ -0,0 +1,235 @@
|
||||
.page {
|
||||
padding: 2rem 3rem;
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.template-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem 2.5rem;
|
||||
|
||||
.col-left, .col-right {
|
||||
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;
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
color: white;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #6c63ff;
|
||||
}
|
||||
}
|
||||
|
||||
textarea { resize: vertical; }
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.76rem;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: #d1d5db;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.fields-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
.field-chip {
|
||||
flex: 1;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
color: white;
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.88rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
|
||||
// Discriminant visuel pour les champs IMAGE (palette indigo).
|
||||
&.field-chip-image {
|
||||
background: #1f1b3a;
|
||||
border-color: #3d3566;
|
||||
color: #c7b8ff;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-type-toggle {
|
||||
width: auto;
|
||||
padding: 0 0.7rem;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.02em;
|
||||
color: #9ca3af;
|
||||
|
||||
&:hover { color: #a5b4fc; background: #1f1b3a; }
|
||||
}
|
||||
|
||||
.type-select {
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
color: white;
|
||||
padding: 0 0.6rem;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus { outline: none; border-color: #6c63ff; }
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
color: white;
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.88rem;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #6c63ff;
|
||||
}
|
||||
|
||||
&::placeholder { color: #6b7280; }
|
||||
}
|
||||
|
||||
&.add-row {
|
||||
margin-top: 0.25rem;
|
||||
border: 1px dashed #2a2a3d;
|
||||
border-radius: 6px;
|
||||
padding: 0;
|
||||
|
||||
input {
|
||||
border: none;
|
||||
background: transparent;
|
||||
&:focus { border: none; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-icon-ghost {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
|
||||
&:hover { color: #fca5a5; background: #2a1f1f; }
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin-right: 0.4rem;
|
||||
background: transparent;
|
||||
color: #6c63ff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover { background: #2a2a3d; }
|
||||
}
|
||||
|
||||
.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 { background: #5a2a2a; }
|
||||
}
|
||||
136
web/src/app/lore/template-edit/template-edit.component.ts
Normal file
136
web/src/app/lore/template-edit/template-edit.component.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule, Plus, X, Trash2, Type, Image as ImageIcon } 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 { FieldType, Template, TemplateField } from '../../services/template.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
|
||||
/**
|
||||
* Écran d'édition d'un Template existant.
|
||||
* Mêmes champs que la création + bouton Supprimer.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-template-edit',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, ReactiveFormsModule, LucideAngularModule],
|
||||
templateUrl: './template-edit.component.html',
|
||||
styleUrls: ['./template-edit.component.scss']
|
||||
})
|
||||
export class TemplateEditComponent implements OnInit, OnDestroy {
|
||||
readonly Plus = Plus;
|
||||
readonly X = X;
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Type = Type;
|
||||
readonly ImageIcon = ImageIcon;
|
||||
|
||||
form: FormGroup;
|
||||
loreId = '';
|
||||
templateId = '';
|
||||
template: Template | null = null;
|
||||
nodes: LoreNode[] = [];
|
||||
fields: TemplateField[] = [];
|
||||
newFieldName = '';
|
||||
newFieldType: FieldType = 'TEXT';
|
||||
|
||||
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],
|
||||
description: [''],
|
||||
defaultNodeId: ['']
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
|
||||
this.templateId = this.route.snapshot.paramMap.get('templateId')!;
|
||||
|
||||
forkJoin({
|
||||
sidebar: loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService),
|
||||
template: this.templateService.getById(this.templateId)
|
||||
}).subscribe(({ sidebar, template }) => {
|
||||
this.nodes = sidebar.nodes;
|
||||
this.layoutService.show(buildLoreSidebarConfig(sidebar));
|
||||
this.hydrate(template);
|
||||
});
|
||||
}
|
||||
|
||||
private hydrate(template: Template): void {
|
||||
this.template = template;
|
||||
// Copie defensive + normalisation du type (defaut TEXT si inconnu/manquant,
|
||||
// utile pour les templates legacy cote frontend meme si le backend le fait aussi).
|
||||
this.fields = (template.fields ?? []).map(f => ({
|
||||
name: f.name,
|
||||
type: f.type === 'IMAGE' ? 'IMAGE' : 'TEXT'
|
||||
}));
|
||||
this.form.patchValue({
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
defaultNodeId: template.defaultNodeId ?? ''
|
||||
});
|
||||
this.pageTitleService.set(template.name);
|
||||
}
|
||||
|
||||
addField(): void {
|
||||
const name = this.newFieldName.trim();
|
||||
if (!name) return;
|
||||
if (this.fields.some(f => f.name === name)) return;
|
||||
this.fields = [...this.fields, { name, type: this.newFieldType }];
|
||||
this.newFieldName = '';
|
||||
}
|
||||
|
||||
removeField(index: number): void {
|
||||
this.fields = this.fields.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
/** Bascule le type d'un champ (TEXT <-> IMAGE). */
|
||||
toggleFieldType(index: number): void {
|
||||
const field = this.fields[index];
|
||||
if (!field) return;
|
||||
const nextType: FieldType = field.type === 'TEXT' ? 'IMAGE' : 'TEXT';
|
||||
this.fields = this.fields.map((f, i) => i === index ? { ...f, type: nextType } : f);
|
||||
}
|
||||
|
||||
save(): void {
|
||||
if (this.form.invalid || !this.template) return;
|
||||
const raw = this.form.value;
|
||||
this.templateService.update(this.templateId, {
|
||||
...this.template,
|
||||
name: raw.name,
|
||||
description: raw.description,
|
||||
defaultNodeId: raw.defaultNodeId || null,
|
||||
fields: this.fields
|
||||
}).subscribe({
|
||||
next: () => this.router.navigate(['/lore', this.loreId]),
|
||||
error: () => console.error('Erreur lors de la sauvegarde du template')
|
||||
});
|
||||
}
|
||||
|
||||
delete(): void {
|
||||
if (!confirm(`Supprimer le template "${this.template?.name}" ?`)) return;
|
||||
this.templateService.delete(this.templateId).subscribe({
|
||||
next: () => this.router.navigate(['/lore', this.loreId]),
|
||||
error: () => console.error('Erreur lors de la suppression du template')
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user