Mise en ligne de la version 0.2.0
This commit is contained in:
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user