Ajout de tableaux dans la partie templates / pages de lore : possibilité d'ajouter un tableau multiligne (par exemple pour faire des tableaux d'objets dans les boutiques) ; tableau type liste clé / valeur (pour des statistiques et ce genre de chose).
All checks were successful
All checks were successful
Ajout de la possibilité de lié un PNJ à une page de lore Ajout d'un graphe de liaison entre lore / PNJs Passage en v.0.12.3-beta
This commit is contained in:
@@ -8,6 +8,11 @@
|
||||
<p class="description">{{ lore.description }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-secondary" (click)="openGraph()"
|
||||
title="Visualiser le graphe des pages et de leurs liens (PNJ inclus)">
|
||||
<lucide-icon [img]="Network" [size]="14"></lucide-icon>
|
||||
Graphe
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="startEdit()" title="Modifier le Lore">
|
||||
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
||||
Modifier
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { LucideAngularModule, Folder, Plus, Pencil, Trash2 } from 'lucide-angular';
|
||||
import { LucideAngularModule, Folder, Plus, Pencil, Trash2, Network } from 'lucide-angular';
|
||||
import { LoreService } from '../../services/lore.service';
|
||||
import { TemplateService } from '../../services/template.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
@@ -23,6 +23,7 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
|
||||
readonly Plus = Plus;
|
||||
readonly Pencil = Pencil;
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Network = Network;
|
||||
|
||||
lore: Lore | null = null;
|
||||
/** Tous les dossiers du Lore (racines + enfants). */
|
||||
@@ -81,6 +82,13 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
|
||||
this.router.navigate(['/lore', this.lore!.id, 'folders', nodeId]);
|
||||
}
|
||||
|
||||
/** Ouvre la vue graphe : pages du Lore + PNJ liés, reliés par leurs liens. */
|
||||
openGraph(): void {
|
||||
if (this.lore?.id) {
|
||||
this.router.navigate(['/lore', this.lore.id, 'graph']);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────── Édition / suppression du Lore ───────────────
|
||||
|
||||
startEdit(): void {
|
||||
|
||||
71
web/src/app/lore/lore-graph/lore-graph.component.html
Normal file
71
web/src/app/lore/lore-graph/lore-graph.component.html
Normal file
@@ -0,0 +1,71 @@
|
||||
@if (lore) {
|
||||
<div class="graph-page">
|
||||
<header class="graph-header">
|
||||
<button type="button" class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
Retour au Lore
|
||||
</button>
|
||||
<div class="graph-title">
|
||||
<h1>
|
||||
<lucide-icon [img]="Network" [size]="20"></lucide-icon>
|
||||
{{ lore.name }} — Graphe
|
||||
</h1>
|
||||
<p class="graph-subtitle">
|
||||
{{ nodes.length - npcCount }} page(s) · {{ npcCount }} PNJ · {{ edgeCount }} lien(s).
|
||||
Cliquez sur un nœud pour l'ouvrir, glissez-le pour réarranger.
|
||||
</p>
|
||||
</div>
|
||||
<div class="graph-legend">
|
||||
<span class="legend-item"><span class="legend-dot legend-dot--page"></span> Page de Lore</span>
|
||||
<span class="legend-item"><span class="legend-dot legend-dot--npc"></span> PNJ</span>
|
||||
<span class="legend-item"><span class="legend-line legend-line--npc"></span> Lien PNJ → page</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (nodes.length === 0) {
|
||||
<div class="graph-empty">
|
||||
Aucune page dans ce Lore pour l'instant — le graphe se remplira au fur et à mesure.
|
||||
</div>
|
||||
} @else {
|
||||
<div class="graph-scroll">
|
||||
<svg #svgEl
|
||||
[attr.width]="svgWidth"
|
||||
[attr.height]="svgHeight"
|
||||
(pointermove)="onPointerMove($event)"
|
||||
(pointerup)="onPointerUp($event)"
|
||||
(pointerleave)="onPointerUp($event)">
|
||||
|
||||
<!-- Arêtes (sous les nœuds) -->
|
||||
@for (e of edges; track e.key) {
|
||||
<line
|
||||
[attr.x1]="e.x1" [attr.y1]="e.y1"
|
||||
[attr.x2]="e.x2" [attr.y2]="e.y2"
|
||||
[class.edge-page]="e.kind === 'page'"
|
||||
[class.edge-npc]="e.kind === 'npc'" />
|
||||
}
|
||||
|
||||
<!-- Nœuds -->
|
||||
@for (n of nodes; track n.id) {
|
||||
<g class="node"
|
||||
[class.node--npc]="n.kind === 'npc'"
|
||||
[class.dragging]="draggingId === n.id"
|
||||
(pointerdown)="onPointerDown($event, n)">
|
||||
<circle [attr.cx]="n.x" [attr.cy]="n.y" [attr.r]="radiusOf(n)" />
|
||||
<text class="node-label"
|
||||
[attr.x]="n.x"
|
||||
[attr.y]="n.y + radiusOf(n) + 14"
|
||||
text-anchor="middle">{{ n.displayLabel }}</text>
|
||||
<title>{{ n.label }}</title>
|
||||
</g>
|
||||
}
|
||||
</svg>
|
||||
</div>
|
||||
@if (edgeCount === 0) {
|
||||
<p class="graph-hint">
|
||||
Aucun lien pour l'instant : liez des pages entre elles (« Pages liées » d'une page)
|
||||
ou rattachez un PNJ à des pages de Lore depuis sa fiche.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
163
web/src/app/lore/lore-graph/lore-graph.component.scss
Normal file
163
web/src/app/lore/lore-graph/lore-graph.component.scss
Normal file
@@ -0,0 +1,163 @@
|
||||
.graph-page {
|
||||
padding: 1.5rem 2rem;
|
||||
}
|
||||
|
||||
.graph-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1.25rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.btn-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 4px;
|
||||
color: #d1d5db;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
|
||||
}
|
||||
|
||||
.graph-title {
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
|
||||
h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.4rem;
|
||||
color: white;
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
.graph-subtitle {
|
||||
color: #9ca3af;
|
||||
font-size: 0.82rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.graph-legend {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.78rem;
|
||||
color: #9ca3af;
|
||||
|
||||
.legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
|
||||
&.legend-dot--page { background: #4338ca; border: 1px solid #818cf8; }
|
||||
&.legend-dot--npc { background: #92400e; border: 1px solid #fbbf24; }
|
||||
}
|
||||
|
||||
.legend-line {
|
||||
width: 18px;
|
||||
height: 0;
|
||||
display: inline-block;
|
||||
|
||||
&.legend-line--npc { border-top: 2px dashed #d97706; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Le SVG peut dépasser l'écran sur un gros lore : scroll dans les 2 sens.
|
||||
.graph-scroll {
|
||||
overflow: auto;
|
||||
border: 1px solid #1e1e3a;
|
||||
border-radius: 8px;
|
||||
background:
|
||||
radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.05) 1px, transparent 0) 0 0 / 26px 26px,
|
||||
#0d0d1c;
|
||||
}
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
touch-action: none; // requis pour le drag au pointeur sur tactile
|
||||
|
||||
line {
|
||||
&.edge-page {
|
||||
stroke: #4f4f7a;
|
||||
stroke-width: 1.6;
|
||||
}
|
||||
|
||||
&.edge-npc {
|
||||
stroke: #d97706;
|
||||
stroke-width: 1.4;
|
||||
stroke-dasharray: 5 4;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.node {
|
||||
cursor: grab;
|
||||
|
||||
circle {
|
||||
fill: #1e1b4b;
|
||||
stroke: #6366f1;
|
||||
stroke-width: 2;
|
||||
transition: stroke 0.12s, fill 0.12s;
|
||||
}
|
||||
|
||||
.node-label {
|
||||
fill: #c7d2fe;
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&:hover circle {
|
||||
fill: #312e81;
|
||||
stroke: #a5b4fc;
|
||||
}
|
||||
|
||||
&.dragging { cursor: grabbing; }
|
||||
|
||||
// PNJ : palette ambre, distincte des pages.
|
||||
&.node--npc {
|
||||
circle {
|
||||
fill: #451a03;
|
||||
stroke: #d97706;
|
||||
}
|
||||
|
||||
.node-label { fill: #fcd34d; }
|
||||
|
||||
&:hover circle {
|
||||
fill: #78350f;
|
||||
stroke: #fbbf24;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.graph-empty {
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
border: 1px dashed #2a2a3d;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.graph-hint {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
350
web/src/app/lore/lore-graph/lore-graph.component.ts
Normal file
350
web/src/app/lore/lore-graph/lore-graph.component.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import { Component, OnInit, OnDestroy, ElementRef, ViewChild } from '@angular/core';
|
||||
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule, ArrowLeft, Network } from 'lucide-angular';
|
||||
import { LoreService } from '../../services/lore.service';
|
||||
import { TemplateService } from '../../services/template.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { NpcService } from '../../services/npc.service';
|
||||
import { LayoutService } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { Lore } from '../../services/lore.model';
|
||||
import { Page } from '../../services/page.model';
|
||||
import { Npc } from '../../services/npc.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
|
||||
/** Nœud du graphe : une page de Lore ou un PNJ qui référence des pages. */
|
||||
interface GraphNode {
|
||||
id: string; // 'page:<id>' ou 'npc:<id>' (évite les collisions d'IDs)
|
||||
kind: 'page' | 'npc';
|
||||
label: string;
|
||||
displayLabel: string;
|
||||
route: string[]; // navigation au clic
|
||||
x: number; // centre du nœud (coords SVG)
|
||||
y: number;
|
||||
degree: number; // nombre de liens (taille du nœud)
|
||||
}
|
||||
|
||||
interface GraphEdge {
|
||||
key: string;
|
||||
kind: 'page' | 'npc'; // page↔page ou npc→page (style distinct)
|
||||
x1: number; y1: number; x2: number; y2: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Graphe du Lore : vue d'ensemble des pages et de leurs liens.
|
||||
*
|
||||
* Nœuds = toutes les pages du Lore + les PNJ (toutes campagnes liées au Lore)
|
||||
* qui référencent au moins une page. Arêtes = `relatedPageIds` des pages
|
||||
* (liens page↔page) et des PNJ (liens PNJ→page).
|
||||
*
|
||||
* Layout force-directed (Fruchterman-Reingold simplifié) calculé une fois au
|
||||
* chargement, puis nœuds déplaçables à la souris — même approche SVG custom
|
||||
* que chapter-graph, sans dépendance externe.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-lore-graph',
|
||||
imports: [RouterModule, LucideAngularModule],
|
||||
templateUrl: './lore-graph.component.html',
|
||||
styleUrls: ['./lore-graph.component.scss']
|
||||
})
|
||||
export class LoreGraphComponent implements OnInit, OnDestroy {
|
||||
readonly ArrowLeft = ArrowLeft;
|
||||
readonly Network = Network;
|
||||
|
||||
loreId = '';
|
||||
lore: Lore | null = null;
|
||||
|
||||
nodes: GraphNode[] = [];
|
||||
edges: GraphEdge[] = [];
|
||||
npcCount = 0;
|
||||
edgeCount = 0;
|
||||
|
||||
readonly MAX_LABEL_CHARS = 22;
|
||||
private readonly MARGIN = 70;
|
||||
|
||||
svgWidth = 800;
|
||||
svgHeight = 600;
|
||||
|
||||
@ViewChild('svgEl') svgEl?: ElementRef<SVGSVGElement>;
|
||||
|
||||
draggingId: string | null = null;
|
||||
private dragOffsetX = 0;
|
||||
private dragOffsetY = 0;
|
||||
private dragMoved = false;
|
||||
private readonly DRAG_THRESHOLD = 4;
|
||||
|
||||
// Adjacence (ids de nœuds) — sert à recalculer les arêtes après un drag.
|
||||
private adjacency: Array<{ key: string; kind: 'page' | 'npc'; a: string; b: string }> = [];
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private loreService: LoreService,
|
||||
private templateService: TemplateService,
|
||||
private pageService: PageService,
|
||||
private npcService: NpcService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
|
||||
forkJoin({
|
||||
sidebar: loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService),
|
||||
npcs: this.npcService.getByLore(this.loreId)
|
||||
}).subscribe(({ sidebar, npcs }) => {
|
||||
this.lore = sidebar.lore;
|
||||
this.layoutService.show(buildLoreSidebarConfig(sidebar));
|
||||
this.pageTitleService.set(`${sidebar.lore.name} — Graphe`);
|
||||
this.buildGraph(sidebar.pages, npcs);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Construction du graphe ----------------------------------------------
|
||||
|
||||
private buildGraph(pages: Page[], npcs: Npc[]): void {
|
||||
const pageIds = new Set(pages.map(p => p.id!));
|
||||
|
||||
// Nœuds pages (toutes, même isolées : la vue d'ensemble inclut les orphelines).
|
||||
const nodes: GraphNode[] = pages.map(p => ({
|
||||
id: `page:${p.id}`,
|
||||
kind: 'page' as const,
|
||||
label: p.title,
|
||||
displayLabel: this.truncate(p.title),
|
||||
route: ['/lore', this.loreId, 'pages', p.id!],
|
||||
x: 0, y: 0, degree: 0
|
||||
}));
|
||||
|
||||
// Nœuds PNJ : seulement ceux qui référencent au moins une page de CE lore
|
||||
// (un PNJ sans lien n'apporte rien à la carte des connexions).
|
||||
const linkedNpcs = npcs.filter(n =>
|
||||
(n.relatedPageIds ?? []).some(pid => pageIds.has(pid)));
|
||||
for (const n of linkedNpcs) {
|
||||
nodes.push({
|
||||
id: `npc:${n.id}`,
|
||||
kind: 'npc',
|
||||
label: n.name,
|
||||
displayLabel: this.truncate(n.name),
|
||||
route: ['/campaigns', n.campaignId, 'npcs', n.id!],
|
||||
x: 0, y: 0, degree: 0
|
||||
});
|
||||
}
|
||||
this.npcCount = linkedNpcs.length;
|
||||
|
||||
// Arêtes. Les liens page↔page sont dé-dupliqués par paire non-orientée
|
||||
// (A→B et B→A = un seul trait).
|
||||
const adjacency: Array<{ key: string; kind: 'page' | 'npc'; a: string; b: string }> = [];
|
||||
const seenPairs = new Set<string>();
|
||||
for (const p of pages) {
|
||||
for (const targetId of p.relatedPageIds ?? []) {
|
||||
if (!pageIds.has(targetId) || targetId === p.id) continue;
|
||||
const pair = [p.id!, targetId].sort().join('|');
|
||||
if (seenPairs.has(pair)) continue;
|
||||
seenPairs.add(pair);
|
||||
adjacency.push({ key: `pp:${pair}`, kind: 'page', a: `page:${p.id}`, b: `page:${targetId}` });
|
||||
}
|
||||
}
|
||||
for (const n of linkedNpcs) {
|
||||
for (const targetId of new Set(n.relatedPageIds ?? [])) {
|
||||
if (!pageIds.has(targetId)) continue;
|
||||
adjacency.push({ key: `np:${n.id}|${targetId}`, kind: 'npc', a: `npc:${n.id}`, b: `page:${targetId}` });
|
||||
}
|
||||
}
|
||||
this.adjacency = adjacency;
|
||||
this.edgeCount = adjacency.length;
|
||||
|
||||
// Degré (pondère la taille des nœuds : une capitale très liée ressort).
|
||||
const degree = new Map<string, number>();
|
||||
for (const e of adjacency) {
|
||||
degree.set(e.a, (degree.get(e.a) ?? 0) + 1);
|
||||
degree.set(e.b, (degree.get(e.b) ?? 0) + 1);
|
||||
}
|
||||
for (const node of nodes) {
|
||||
node.degree = degree.get(node.id) ?? 0;
|
||||
}
|
||||
|
||||
this.nodes = nodes;
|
||||
this.runForceLayout();
|
||||
this.recomputeEdges();
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout force-directed (Fruchterman-Reingold simplifié) :
|
||||
* répulsion entre tous les nœuds, ressorts sur les arêtes, gravité vers le
|
||||
* centre (regroupe les composantes déconnectées), refroidissement progressif.
|
||||
* Positions initiales sur un cercle (déterministe : pas d'aléatoire, cf.
|
||||
* convention projet d'éviter Math.random pour des rendus reproductibles).
|
||||
*/
|
||||
private runForceLayout(): void {
|
||||
const n = this.nodes.length;
|
||||
if (n === 0) {
|
||||
this.svgWidth = 800; this.svgHeight = 400;
|
||||
return;
|
||||
}
|
||||
const side = Math.max(600, Math.ceil(170 * Math.sqrt(n)));
|
||||
const w = side, h = side;
|
||||
const cx = w / 2, cy = h / 2;
|
||||
|
||||
// Init en spirale : angle d'or → répartition uniforme et déterministe.
|
||||
const golden = Math.PI * (3 - Math.sqrt(5));
|
||||
this.nodes.forEach((node, i) => {
|
||||
const r = (Math.sqrt(i + 0.5) / Math.sqrt(n)) * (side / 2 - this.MARGIN);
|
||||
const a = i * golden;
|
||||
node.x = cx + r * Math.cos(a);
|
||||
node.y = cy + r * Math.sin(a);
|
||||
});
|
||||
|
||||
const index = new Map(this.nodes.map(node => [node.id, node]));
|
||||
const k = 0.9 * Math.sqrt((w * h) / n); // distance "idéale" entre nœuds
|
||||
let temperature = side / 8;
|
||||
|
||||
for (let iter = 0; iter < 300; iter++) {
|
||||
const dx = new Map<string, number>();
|
||||
const dy = new Map<string, number>();
|
||||
for (const node of this.nodes) { dx.set(node.id, 0); dy.set(node.id, 0); }
|
||||
|
||||
// Répulsion entre toutes les paires.
|
||||
for (let i = 0; i < n; i++) {
|
||||
for (let j = i + 1; j < n; j++) {
|
||||
const a = this.nodes[i], b = this.nodes[j];
|
||||
let vx = a.x - b.x, vy = a.y - b.y;
|
||||
let d = Math.hypot(vx, vy);
|
||||
if (d < 0.01) { vx = 0.1 * ((i % 3) - 1) || 0.1; vy = 0.1; d = Math.hypot(vx, vy); }
|
||||
const force = (k * k) / d;
|
||||
dx.set(a.id, dx.get(a.id)! + (vx / d) * force);
|
||||
dy.set(a.id, dy.get(a.id)! + (vy / d) * force);
|
||||
dx.set(b.id, dx.get(b.id)! - (vx / d) * force);
|
||||
dy.set(b.id, dy.get(b.id)! - (vy / d) * force);
|
||||
}
|
||||
}
|
||||
|
||||
// Attraction le long des arêtes.
|
||||
for (const e of this.adjacency) {
|
||||
const a = index.get(e.a)!, b = index.get(e.b)!;
|
||||
const vx = a.x - b.x, vy = a.y - b.y;
|
||||
const d = Math.max(0.01, Math.hypot(vx, vy));
|
||||
const force = (d * d) / k;
|
||||
dx.set(a.id, dx.get(a.id)! - (vx / d) * force);
|
||||
dy.set(a.id, dy.get(a.id)! - (vy / d) * force);
|
||||
dx.set(b.id, dx.get(b.id)! + (vx / d) * force);
|
||||
dy.set(b.id, dy.get(b.id)! + (vy / d) * force);
|
||||
}
|
||||
|
||||
// Gravité douce vers le centre (sinon les composantes isolées fuient).
|
||||
for (const node of this.nodes) {
|
||||
dx.set(node.id, dx.get(node.id)! + (cx - node.x) * 0.06);
|
||||
dy.set(node.id, dy.get(node.id)! + (cy - node.y) * 0.06);
|
||||
}
|
||||
|
||||
// Application bornée par la température, dans le cadre.
|
||||
for (const node of this.nodes) {
|
||||
const ddx = dx.get(node.id)!, ddy = dy.get(node.id)!;
|
||||
const d = Math.max(0.01, Math.hypot(ddx, ddy));
|
||||
const step = Math.min(d, temperature);
|
||||
node.x = Math.min(w - this.MARGIN, Math.max(this.MARGIN, node.x + (ddx / d) * step));
|
||||
node.y = Math.min(h - this.MARGIN, Math.max(this.MARGIN, node.y + (ddy / d) * step));
|
||||
}
|
||||
temperature *= 0.96;
|
||||
}
|
||||
|
||||
this.svgWidth = w;
|
||||
this.svgHeight = h;
|
||||
}
|
||||
|
||||
/** Recalcule la géométrie des arêtes depuis les positions courantes des nœuds. */
|
||||
private recomputeEdges(): void {
|
||||
const index = new Map(this.nodes.map(n => [n.id, n]));
|
||||
this.edges = this.adjacency
|
||||
.filter(e => index.has(e.a) && index.has(e.b))
|
||||
.map(e => {
|
||||
const a = index.get(e.a)!, b = index.get(e.b)!;
|
||||
return { key: e.key, kind: e.kind, x1: a.x, y1: a.y, x2: b.x, y2: b.y };
|
||||
});
|
||||
}
|
||||
|
||||
/** Rayon d'un nœud : grossit doucement avec son nombre de liens. */
|
||||
radiusOf(node: GraphNode): number {
|
||||
return 14 + Math.min(10, node.degree * 1.5);
|
||||
}
|
||||
|
||||
// --- Interactions (drag pour réarranger, clic pour ouvrir) ----------------
|
||||
|
||||
private toSvgCoords(evt: PointerEvent): { x: number; y: number } {
|
||||
const svg = this.svgEl?.nativeElement;
|
||||
if (!svg) return { x: evt.clientX, y: evt.clientY };
|
||||
const pt = svg.createSVGPoint();
|
||||
pt.x = evt.clientX;
|
||||
pt.y = evt.clientY;
|
||||
const ctm = svg.getScreenCTM();
|
||||
if (!ctm) return { x: evt.clientX, y: evt.clientY };
|
||||
const local = pt.matrixTransform(ctm.inverse());
|
||||
return { x: local.x, y: local.y };
|
||||
}
|
||||
|
||||
onPointerDown(evt: PointerEvent, node: GraphNode): void {
|
||||
if (evt.button !== 0) return;
|
||||
evt.preventDefault();
|
||||
const { x, y } = this.toSvgCoords(evt);
|
||||
this.draggingId = node.id;
|
||||
this.dragOffsetX = x - node.x;
|
||||
this.dragOffsetY = y - node.y;
|
||||
this.dragMoved = false;
|
||||
(evt.target as Element).setPointerCapture?.(evt.pointerId);
|
||||
}
|
||||
|
||||
onPointerMove(evt: PointerEvent): void {
|
||||
if (!this.draggingId) return;
|
||||
const node = this.nodes.find(n => n.id === this.draggingId);
|
||||
if (!node) return;
|
||||
const { x, y } = this.toSvgCoords(evt);
|
||||
const newX = Math.max(this.MARGIN / 2, x - this.dragOffsetX);
|
||||
const newY = Math.max(this.MARGIN / 2, y - this.dragOffsetY);
|
||||
if (!this.dragMoved) {
|
||||
if (Math.hypot(newX - node.x, newY - node.y) < this.DRAG_THRESHOLD) return;
|
||||
this.dragMoved = true;
|
||||
}
|
||||
node.x = newX;
|
||||
node.y = newY;
|
||||
this.recomputeEdges();
|
||||
this.fitSvgToNodes();
|
||||
}
|
||||
|
||||
onPointerUp(evt: PointerEvent): void {
|
||||
if (!this.draggingId) return;
|
||||
const id = this.draggingId;
|
||||
const moved = this.dragMoved;
|
||||
this.draggingId = null;
|
||||
this.dragMoved = false;
|
||||
(evt.target as Element).releasePointerCapture?.(evt.pointerId);
|
||||
if (moved) return;
|
||||
// Clic simple → ouvre la page / la fiche PNJ.
|
||||
const node = this.nodes.find(n => n.id === id);
|
||||
if (node) this.router.navigate(node.route);
|
||||
}
|
||||
|
||||
/** Agrandit le SVG si un nœud déplacé s'approche du bord (jamais de réduction). */
|
||||
private fitSvgToNodes(): void {
|
||||
for (const n of this.nodes) {
|
||||
if (n.x + this.MARGIN > this.svgWidth) this.svgWidth = n.x + this.MARGIN;
|
||||
if (n.y + this.MARGIN > this.svgHeight) this.svgHeight = n.y + this.MARGIN;
|
||||
}
|
||||
}
|
||||
|
||||
private truncate(text: string): string {
|
||||
return text.length > this.MAX_LABEL_CHARS
|
||||
? text.slice(0, this.MAX_LABEL_CHARS - 1) + '…'
|
||||
: text;
|
||||
}
|
||||
|
||||
back(): void {
|
||||
this.router.navigate(['/lore', this.loreId]);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||
// show(). Eviter d'appeler hide() ici previent le clignotement.
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,75 @@
|
||||
</app-image-gallery>
|
||||
</div>
|
||||
}
|
||||
<!-- Champ KEY_VALUE_LIST : liste libellé/valeur (labels figés par le template). -->
|
||||
@if (field.type === 'KEY_VALUE_LIST') {
|
||||
<div class="field">
|
||||
<label>{{ field.name }}</label>
|
||||
<div class="kv-grid">
|
||||
@for (lbl of field.labels ?? []; track $index) {
|
||||
<div class="kv-cell">
|
||||
<span class="kv-label">{{ lbl }}</span>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="keyValueValues[field.name][lbl]"
|
||||
[name]="'kv_' + field.name + '_' + lbl"
|
||||
placeholder="—" />
|
||||
</div>
|
||||
}
|
||||
@if (!(field.labels ?? []).length) {
|
||||
<p class="kv-empty">Aucun libellé défini dans le template pour ce champ.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Champ TABLE : colonnes figées par le template, lignes libres. -->
|
||||
@if (field.type === 'TABLE') {
|
||||
<div class="field">
|
||||
<label>{{ field.name }}</label>
|
||||
@if ((field.labels ?? []).length) {
|
||||
<div class="table-edit-wrap">
|
||||
<table class="table-edit">
|
||||
<thead>
|
||||
<tr>
|
||||
@for (col of field.labels; track $index) {
|
||||
<th>{{ col }}</th>
|
||||
}
|
||||
<th class="table-edit-actions-col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of tableValues[field.name] ?? []; track $index; let ri = $index) {
|
||||
<tr>
|
||||
@for (col of field.labels; track $index) {
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="row[col]"
|
||||
[name]="'tbl_' + field.name + '_' + ri + '_' + col"
|
||||
[placeholder]="col" />
|
||||
</td>
|
||||
}
|
||||
<td class="table-edit-actions-col">
|
||||
<button type="button" class="btn-row-delete"
|
||||
(click)="removeTableRow(field.name, ri)"
|
||||
[attr.aria-label]="'Supprimer la ligne ' + (ri + 1)" title="Supprimer la ligne">
|
||||
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<button type="button" class="btn-row-add" (click)="addTableRow(field.name, field.labels)">
|
||||
<lucide-icon [img]="Plus" [size]="13"></lucide-icon>
|
||||
Ajouter une ligne
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="kv-empty">Aucune colonne définie dans le template pour ce tableau.</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
<!-- Tags --------------------------------------------------------- -->
|
||||
|
||||
@@ -126,6 +126,127 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Grille de saisie d'un champ Tableau (KEY_VALUE_LIST) — même esthétique
|
||||
// que la grille des fiches de personnage (dynamic-fields-form).
|
||||
.kv-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||||
gap: 8px;
|
||||
|
||||
.kv-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 8px 6px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 4px;
|
||||
|
||||
.kv-label {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 4px 6px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 3px;
|
||||
color: white;
|
||||
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
|
||||
&:focus { outline: none; border-color: #6c63ff; }
|
||||
}
|
||||
}
|
||||
|
||||
.kv-empty {
|
||||
padding: 8px;
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Éditeur d'un champ Tableau (TABLE) : colonnes du template, lignes libres.
|
||||
.table-edit-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.table-edit {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #99f6e4;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid rgba(20, 184, 166, 0.35);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 4px 4px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 3px;
|
||||
color: white;
|
||||
font-size: 0.88rem;
|
||||
|
||||
&:focus { outline: none; border-color: #14b8a6; }
|
||||
}
|
||||
}
|
||||
|
||||
.table-edit-actions-col {
|
||||
width: 34px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-row-delete {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px;
|
||||
|
||||
&:hover { color: #f87171; }
|
||||
}
|
||||
}
|
||||
|
||||
.btn-row-add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
background: transparent;
|
||||
border: 1px dashed rgba(20, 184, 166, 0.5);
|
||||
border-radius: 5px;
|
||||
color: #5eead4;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover { background: rgba(20, 184, 166, 0.08); }
|
||||
}
|
||||
|
||||
.ai-error-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule, Sparkles } from 'lucide-angular';
|
||||
import { LucideAngularModule, Sparkles, Plus, Trash2 } from 'lucide-angular';
|
||||
import { LoreService } from '../../services/lore.service';
|
||||
import { TemplateService } from '../../services/template.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
@@ -42,6 +42,8 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog
|
||||
})
|
||||
export class PageEditComponent implements OnInit, OnDestroy {
|
||||
readonly Sparkles = Sparkles;
|
||||
readonly Plus = Plus;
|
||||
readonly Trash2 = Trash2;
|
||||
|
||||
loreId = '';
|
||||
pageId = '';
|
||||
@@ -63,6 +65,10 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
||||
* la liste ordonnee des IDs d'images uploadees.
|
||||
*/
|
||||
imageValues: Record<string, string[]> = {};
|
||||
/** Valeurs des champs KEY_VALUE_LIST (liste clé/valeur) : fieldName → (label → valeur). */
|
||||
keyValueValues: Record<string, Record<string, string>> = {};
|
||||
/** Valeurs des champs TABLE : fieldName → lignes (colonne → cellule). */
|
||||
tableValues: Record<string, Array<Record<string, string>>> = {};
|
||||
/** Étiquettes libres (Phase 5B). */
|
||||
tags: string[] = [];
|
||||
/** IDs des pages liées (Phase 5B). */
|
||||
@@ -169,16 +175,27 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
||||
// structure `imageValues: Map<String, List<String>>` a l'etape 5).
|
||||
const base: Record<string, string> = {};
|
||||
const imageBase: Record<string, string[]> = {};
|
||||
const kvBase: Record<string, Record<string, string>> = {};
|
||||
const tableBase: Record<string, Array<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] ?? [])];
|
||||
} else if (f.type === 'KEY_VALUE_LIST') {
|
||||
// Toujours initialiser l'objet interne : le ngModel du formulaire
|
||||
// bind directement keyValueValues[field.name][label].
|
||||
kvBase[f.name] = { ...(page.keyValueValues?.[f.name] ?? {}) };
|
||||
} else if (f.type === 'TABLE') {
|
||||
// Copie profonde des lignes : chaque ligne est éditée par ngModel.
|
||||
tableBase[f.name] = (page.tableValues?.[f.name] ?? []).map(row => ({ ...row }));
|
||||
}
|
||||
}
|
||||
this.values = base;
|
||||
this.imageValues = imageBase;
|
||||
this.keyValueValues = kvBase;
|
||||
this.tableValues = tableBase;
|
||||
this.tags = [...(page.tags ?? [])];
|
||||
this.relatedPageIds = [...(page.relatedPageIds ?? [])];
|
||||
this.pageTitleService.set(page.title);
|
||||
@@ -193,6 +210,8 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
||||
notes: this.notes,
|
||||
values: this.values,
|
||||
imageValues: this.imageValues,
|
||||
keyValueValues: this.keyValueValues,
|
||||
tableValues: this.tableValues,
|
||||
tags: this.tags,
|
||||
relatedPageIds: this.relatedPageIds
|
||||
};
|
||||
@@ -202,6 +221,20 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
// --- Champs TABLE (lignes libres) ---------------------------------------
|
||||
// Mutation en place des lignes : recréer le tableau à chaque frappe ferait
|
||||
// perdre le focus de la cellule en cours d'édition.
|
||||
|
||||
addTableRow(fieldName: string, columns: string[] | null | undefined): void {
|
||||
const row: Record<string, string> = {};
|
||||
for (const col of columns ?? []) row[col] = '';
|
||||
(this.tableValues[fieldName] ??= []).push(row);
|
||||
}
|
||||
|
||||
removeTableRow(fieldName: string, rowIndex: number): void {
|
||||
this.tableValues[fieldName]?.splice(rowIndex, 1);
|
||||
}
|
||||
|
||||
// --- Chat IA conversationnel (Phase b5) --------------------------------
|
||||
|
||||
toggleChat(): void {
|
||||
|
||||
@@ -40,6 +40,50 @@
|
||||
</app-image-gallery>
|
||||
</section>
|
||||
}
|
||||
@if (field.type === 'KEY_VALUE_LIST') {
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title">{{ field.name }}</h2>
|
||||
@if (kvHasContent(field.name, field.labels)) {
|
||||
<div class="view-kv-grid">
|
||||
@for (lbl of field.labels ?? []; track $index) {
|
||||
<div class="view-kv-cell">
|
||||
<span class="view-kv-label">{{ lbl }}</span>
|
||||
<span class="view-kv-value">{{ kvValueOf(field.name, lbl) || '—' }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p class="view-section-empty">Non renseigné</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@if (field.type === 'TABLE') {
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title">{{ field.name }}</h2>
|
||||
@if (tableRowsOf(field.name).length) {
|
||||
<table class="view-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@for (col of field.labels ?? []; track $index) {
|
||||
<th>{{ col }}</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of tableRowsOf(field.name); track $index) {
|
||||
<tr>
|
||||
@for (col of field.labels ?? []; track $index) {
|
||||
<td>{{ row[col] || '—' }}</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
} @else {
|
||||
<p class="view-section-empty">Non renseigné</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
}
|
||||
}
|
||||
<!-- Tags -->
|
||||
|
||||
@@ -114,6 +114,21 @@ export class PageViewComponent implements OnInit, OnDestroy {
|
||||
return this.page?.imageValues?.[fieldName] ?? [];
|
||||
}
|
||||
|
||||
/** Valeur d'un libellé d'un champ KEY_VALUE_LIST (tableau). */
|
||||
kvValueOf(fieldName: string, label: string): string {
|
||||
return this.page?.keyValueValues?.[fieldName]?.[label] ?? '';
|
||||
}
|
||||
|
||||
/** True si au moins une valeur de la liste clé/valeur est renseignée. */
|
||||
kvHasContent(fieldName: string, labels: string[] | null | undefined): boolean {
|
||||
return (labels ?? []).some(lbl => this.kvValueOf(fieldName, lbl).trim() !== '');
|
||||
}
|
||||
|
||||
/** Lignes d'un champ TABLE (liste vide si jamais rempli). */
|
||||
tableRowsOf(fieldName: string): Array<Record<string, string>> {
|
||||
return this.page?.tableValues?.[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)';
|
||||
|
||||
@@ -66,17 +66,24 @@
|
||||
<lucide-icon [img]="ChevronDown" [size]="12"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
<span class="field-chip" [class.field-chip-image]="f.type === 'IMAGE'">
|
||||
<lucide-icon [img]="f.type === 'IMAGE' ? ImageIcon : Type" [size]="12"></lucide-icon>
|
||||
<span class="field-chip"
|
||||
[class.field-chip-image]="f.type === 'IMAGE'"
|
||||
[class.field-chip-kv]="f.type === 'KEY_VALUE_LIST'"
|
||||
[class.field-chip-table]="f.type === 'TABLE'">
|
||||
<lucide-icon [img]="iconFor(f.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>
|
||||
<select
|
||||
class="type-select"
|
||||
[ngModel]="f.type"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
(ngModelChange)="setFieldType(i, $event)"
|
||||
title="Type du champ">
|
||||
<option value="TEXT">Texte</option>
|
||||
<option value="IMAGE">Image</option>
|
||||
<option value="KEY_VALUE_LIST">Liste clé/valeur</option>
|
||||
<option value="TABLE">Tableau</option>
|
||||
</select>
|
||||
@if (f.type === 'IMAGE') {
|
||||
<select
|
||||
class="layout-select"
|
||||
@@ -94,6 +101,36 @@
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</li>
|
||||
<!-- Libelles (lignes d'une liste clé/valeur, ou colonnes d'un tableau) -->
|
||||
@if (f.type === 'KEY_VALUE_LIST' || f.type === 'TABLE') {
|
||||
<li class="kv-labels-row">
|
||||
@for (lbl of f.labels ?? []; track $index; let li = $index) {
|
||||
<span class="kv-label-chip">
|
||||
<input
|
||||
type="text"
|
||||
[ngModel]="lbl"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
(ngModelChange)="updateLabel(f, li, $event)"
|
||||
[placeholder]="f.type === 'TABLE' ? 'Colonne' : 'Libellé'"
|
||||
[attr.aria-label]="(f.type === 'TABLE' ? 'Colonne ' : 'Libellé ') + (li + 1)" />
|
||||
<button type="button" class="kv-label-remove" (click)="removeLabel(f, li)" aria-label="Retirer">
|
||||
<lucide-icon [img]="X" [size]="11"></lucide-icon>
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
<button type="button" class="btn-kv-add-label" (click)="addLabel(f)">
|
||||
<lucide-icon [img]="Plus" [size]="12"></lucide-icon>
|
||||
{{ f.type === 'TABLE' ? 'Colonne' : 'Libellé' }}
|
||||
</button>
|
||||
@if (!(f.labels ?? []).length) {
|
||||
<span class="kv-labels-hint">
|
||||
{{ f.type === 'TABLE'
|
||||
? 'Ajoutez les colonnes du tableau (ex : Objet, Prix, Description…)'
|
||||
: 'Ajoutez les libellés des lignes (ex : FOR, DEX, CON…)' }}
|
||||
</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
|
||||
@@ -111,13 +148,15 @@
|
||||
aria-label="Type du champ">
|
||||
<option value="TEXT">Texte</option>
|
||||
<option value="IMAGE">Image</option>
|
||||
<option value="KEY_VALUE_LIST">Liste clé/valeur</option>
|
||||
<option value="TABLE">Tableau</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>
|
||||
<p class="hint">Texte = libre + utilisable par l'IA. Image = galerie. Liste clé/valeur = paires libellé/valeur (stats). Tableau = colonnes fixes + lignes ajoutées librement (boutique, inventaire…).</p>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -90,6 +90,70 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
// Sous-ligne des libellés d'un champ Tableau (KEY_VALUE_LIST).
|
||||
.kv-labels-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
// Aligne visuellement sous le chip du champ (décalage = reorder-stack + gap).
|
||||
padding-left: 30px;
|
||||
|
||||
.kv-label-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #4a3a1f;
|
||||
border-radius: 5px;
|
||||
padding: 0.15rem 0.3rem;
|
||||
|
||||
input {
|
||||
width: 90px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #fbd38d;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.2rem 0.3rem;
|
||||
|
||||
&:focus { outline: none; }
|
||||
}
|
||||
|
||||
.kv-label-remove {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px;
|
||||
|
||||
&:hover { color: #f87171; }
|
||||
}
|
||||
}
|
||||
|
||||
.btn-kv-add-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background: transparent;
|
||||
border: 1px dashed #4a3a1f;
|
||||
border-radius: 5px;
|
||||
color: #fbd38d;
|
||||
font-size: 0.78rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover { background: rgba(251, 211, 141, 0.08); }
|
||||
}
|
||||
|
||||
.kv-labels-hint {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -111,6 +175,18 @@
|
||||
background: #312b5c;
|
||||
color: #c7b8ff;
|
||||
}
|
||||
|
||||
// Couleur discriminante pour les champs Liste clé/valeur (palette ambre).
|
||||
&.field-chip-kv {
|
||||
background: #4a3a1f;
|
||||
color: #fbd38d;
|
||||
}
|
||||
|
||||
// Couleur discriminante pour les champs Tableau (palette sarcelle).
|
||||
&.field-chip-table {
|
||||
background: #134e4a;
|
||||
color: #99f6e4;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-type-toggle {
|
||||
|
||||
@@ -2,13 +2,13 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown } from 'lucide-angular';
|
||||
import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown, ListOrdered, Table as TableIcon, X } 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, ImageLayout, TemplateField } from '../../services/template.model';
|
||||
import { FieldType, ImageLayout, TemplateField, buildLoreTemplateField, cleanFieldLabels } from '../../services/template.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
import { popReturnTo } from '../return-stack.helper';
|
||||
|
||||
@@ -31,6 +31,19 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
|
||||
readonly ImageIcon = ImageIcon;
|
||||
readonly ChevronUp = ChevronUp;
|
||||
readonly ChevronDown = ChevronDown;
|
||||
readonly ListOrdered = ListOrdered;
|
||||
readonly TableIcon = TableIcon;
|
||||
readonly X = X;
|
||||
|
||||
/** Icone du chip selon le type du champ. */
|
||||
iconFor(type: FieldType) {
|
||||
switch (type) {
|
||||
case 'IMAGE': return this.ImageIcon;
|
||||
case 'KEY_VALUE_LIST': return this.ListOrdered;
|
||||
case 'TABLE': return this.TableIcon;
|
||||
default: return this.Type;
|
||||
}
|
||||
}
|
||||
|
||||
form: FormGroup;
|
||||
loreId = '';
|
||||
@@ -123,10 +136,7 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
|
||||
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;
|
||||
const newField: TemplateField = this.newFieldType === 'IMAGE'
|
||||
? { name, type: 'IMAGE', layout: 'GALLERY' }
|
||||
: { name, type: 'TEXT' };
|
||||
this.fields = [...this.fields, newField];
|
||||
this.fields = [...this.fields, buildLoreTemplateField(name, this.newFieldType)];
|
||||
this.newFieldName = '';
|
||||
// Le type reste sur la derniere valeur choisie : pratique pour enchainer
|
||||
// plusieurs champs du meme type.
|
||||
@@ -145,17 +155,11 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
|
||||
this.fields = next;
|
||||
}
|
||||
|
||||
/** 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) => {
|
||||
if (i !== index) return f;
|
||||
return nextType === 'IMAGE'
|
||||
? { name: f.name, type: 'IMAGE', layout: f.layout ?? 'GALLERY' }
|
||||
: { name: f.name, type: 'TEXT' };
|
||||
});
|
||||
/** Change le type d'un champ existant (TEXT / IMAGE / KEY_VALUE_LIST). */
|
||||
setFieldType(index: number, type: FieldType): void {
|
||||
this.fields = this.fields.map((f, i) =>
|
||||
i === index ? buildLoreTemplateField(f.name, type, f) : f
|
||||
);
|
||||
}
|
||||
|
||||
/** Met a jour le layout d'un champ IMAGE. */
|
||||
@@ -165,6 +169,24 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Sous-editeur des libelles (KEY_VALUE_LIST) -------------------------
|
||||
// Mutation en place des labels : recreer le tableau de fields a chaque
|
||||
// frappe ferait perdre le focus de l'input en cours d'edition.
|
||||
|
||||
addLabel(field: TemplateField): void {
|
||||
field.labels = [...(field.labels ?? []), ''];
|
||||
}
|
||||
|
||||
updateLabel(field: TemplateField, labelIndex: number, value: string): void {
|
||||
if (!field.labels) return;
|
||||
field.labels[labelIndex] = value;
|
||||
}
|
||||
|
||||
removeLabel(field: TemplateField, labelIndex: number): void {
|
||||
if (!field.labels) return;
|
||||
field.labels = field.labels.filter((_, i) => i !== labelIndex);
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid) return;
|
||||
const raw = this.form.value;
|
||||
@@ -173,7 +195,7 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
|
||||
name: raw.name,
|
||||
description: raw.description,
|
||||
defaultNodeId: raw.defaultNodeId,
|
||||
fields: this.fields
|
||||
fields: cleanFieldLabels(this.fields)
|
||||
}).subscribe({
|
||||
next: (created) => this.navigateBack(created.id ?? null),
|
||||
error: () => console.error('Erreur lors de la création du template')
|
||||
|
||||
@@ -54,17 +54,24 @@
|
||||
</div>
|
||||
<span class="field-chip"
|
||||
[class.field-chip-image]="f.type === 'IMAGE'"
|
||||
[class.field-chip-existing]="f.type !== 'IMAGE' && isExistingField(f)"
|
||||
[class.field-chip-new]="f.type !== 'IMAGE' && !isExistingField(f)">
|
||||
<lucide-icon [img]="f.type === 'IMAGE' ? ImageIcon : Type" [size]="12"></lucide-icon>
|
||||
[class.field-chip-kv]="f.type === 'KEY_VALUE_LIST'"
|
||||
[class.field-chip-table]="f.type === 'TABLE'"
|
||||
[class.field-chip-existing]="f.type === 'TEXT' && isExistingField(f)"
|
||||
[class.field-chip-new]="f.type === 'TEXT' && !isExistingField(f)">
|
||||
<lucide-icon [img]="iconFor(f.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>
|
||||
<select
|
||||
class="type-select"
|
||||
[ngModel]="f.type"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
(ngModelChange)="setFieldType(i, $event)"
|
||||
title="Type du champ">
|
||||
<option value="TEXT">Texte</option>
|
||||
<option value="IMAGE">Image</option>
|
||||
<option value="KEY_VALUE_LIST">Liste clé/valeur</option>
|
||||
<option value="TABLE">Tableau</option>
|
||||
</select>
|
||||
@if (f.type === 'IMAGE') {
|
||||
<select
|
||||
class="layout-select"
|
||||
@@ -82,6 +89,36 @@
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</li>
|
||||
<!-- Libelles (lignes d'une liste clé/valeur, ou colonnes d'un tableau) -->
|
||||
@if (f.type === 'KEY_VALUE_LIST' || f.type === 'TABLE') {
|
||||
<li class="kv-labels-row">
|
||||
@for (lbl of f.labels ?? []; track $index; let li = $index) {
|
||||
<span class="kv-label-chip">
|
||||
<input
|
||||
type="text"
|
||||
[ngModel]="lbl"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
(ngModelChange)="updateLabel(f, li, $event)"
|
||||
[placeholder]="f.type === 'TABLE' ? 'Colonne' : 'Libellé'"
|
||||
[attr.aria-label]="(f.type === 'TABLE' ? 'Colonne ' : 'Libellé ') + (li + 1)" />
|
||||
<button type="button" class="kv-label-remove" (click)="removeLabel(f, li)" aria-label="Retirer">
|
||||
<lucide-icon [img]="X" [size]="11"></lucide-icon>
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
<button type="button" class="btn-kv-add-label" (click)="addLabel(f)">
|
||||
<lucide-icon [img]="Plus" [size]="12"></lucide-icon>
|
||||
{{ f.type === 'TABLE' ? 'Colonne' : 'Libellé' }}
|
||||
</button>
|
||||
@if (!(f.labels ?? []).length) {
|
||||
<span class="kv-labels-hint">
|
||||
{{ f.type === 'TABLE'
|
||||
? 'Ajoutez les colonnes du tableau (ex : Objet, Prix, Description…)'
|
||||
: 'Ajoutez les libellés des lignes (ex : FOR, DEX, CON…)' }}
|
||||
</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
<div class="field-row add-row">
|
||||
@@ -98,12 +135,14 @@
|
||||
aria-label="Type du champ">
|
||||
<option value="TEXT">Texte</option>
|
||||
<option value="IMAGE">Image</option>
|
||||
<option value="KEY_VALUE_LIST">Liste clé/valeur</option>
|
||||
<option value="TABLE">Tableau</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">Texte = zone editable + generable par l'IA. Image = galerie d'illustrations.</p>
|
||||
<p class="hint">Texte = libre + generable par l'IA. Image = galerie. Liste clé/valeur = paires libellé/valeur (stats). Tableau = colonnes fixes + lignes ajoutées librement (boutique, inventaire…).</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -90,6 +90,70 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
// Sous-ligne des libellés d'un champ Tableau (KEY_VALUE_LIST).
|
||||
.kv-labels-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
// Aligne visuellement sous le chip du champ (décalage = reorder-stack + gap).
|
||||
padding-left: 30px;
|
||||
|
||||
.kv-label-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #4a3a1f;
|
||||
border-radius: 5px;
|
||||
padding: 0.15rem 0.3rem;
|
||||
|
||||
input {
|
||||
width: 90px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #fbd38d;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.2rem 0.3rem;
|
||||
|
||||
&:focus { outline: none; }
|
||||
}
|
||||
|
||||
.kv-label-remove {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px;
|
||||
|
||||
&:hover { color: #f87171; }
|
||||
}
|
||||
}
|
||||
|
||||
.btn-kv-add-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background: transparent;
|
||||
border: 1px dashed #4a3a1f;
|
||||
border-radius: 5px;
|
||||
color: #fbd38d;
|
||||
font-size: 0.78rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover { background: rgba(251, 211, 141, 0.08); }
|
||||
}
|
||||
|
||||
.kv-labels-hint {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -127,6 +191,20 @@
|
||||
border-color: #3d3566;
|
||||
color: #c7b8ff;
|
||||
}
|
||||
|
||||
// Champ Liste clé/valeur (palette ambre) — prioritaire sur existing/new.
|
||||
&.field-chip-kv {
|
||||
background: #4a3a1f;
|
||||
border-color: #6b5328;
|
||||
color: #fbd38d;
|
||||
}
|
||||
|
||||
// Champ Tableau (palette sarcelle) — prioritaire sur existing/new.
|
||||
&.field-chip-table {
|
||||
background: #134e4a;
|
||||
border-color: #14b8a6;
|
||||
color: #99f6e4;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-type-toggle {
|
||||
|
||||
@@ -4,14 +4,14 @@ import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators }
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin, Subject } from 'rxjs';
|
||||
import { switchMap, takeUntil } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown } from 'lucide-angular';
|
||||
import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown, ListOrdered, Table as TableIcon, X } 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, ImageLayout, Template, TemplateField } from '../../services/template.model';
|
||||
import { FieldType, ImageLayout, Template, TemplateField, buildLoreTemplateField, cleanFieldLabels } from '../../services/template.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
@@ -32,6 +32,19 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
||||
readonly ImageIcon = ImageIcon;
|
||||
readonly ChevronUp = ChevronUp;
|
||||
readonly ChevronDown = ChevronDown;
|
||||
readonly ListOrdered = ListOrdered;
|
||||
readonly TableIcon = TableIcon;
|
||||
readonly X = X;
|
||||
|
||||
/** Icone du chip selon le type du champ. */
|
||||
iconFor(type: FieldType) {
|
||||
switch (type) {
|
||||
case 'IMAGE': return this.ImageIcon;
|
||||
case 'KEY_VALUE_LIST': return this.ListOrdered;
|
||||
case 'TABLE': return this.TableIcon;
|
||||
default: return this.Type;
|
||||
}
|
||||
}
|
||||
|
||||
form: FormGroup;
|
||||
loreId = '';
|
||||
@@ -100,10 +113,9 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
||||
// 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 => {
|
||||
const type: FieldType = f.type === 'IMAGE' ? 'IMAGE' : 'TEXT';
|
||||
return type === 'IMAGE'
|
||||
? { name: f.name, type, layout: f.layout ?? 'GALLERY' }
|
||||
: { name: f.name, type };
|
||||
const type: FieldType =
|
||||
f.type === 'IMAGE' || f.type === 'KEY_VALUE_LIST' || f.type === 'TABLE' ? f.type : 'TEXT';
|
||||
return buildLoreTemplateField(f.name, type, f);
|
||||
});
|
||||
this.originalFieldNames = new Set(this.fields.map(f => f.name));
|
||||
this.form.patchValue({
|
||||
@@ -118,10 +130,7 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
||||
const name = this.newFieldName.trim();
|
||||
if (!name) return;
|
||||
if (this.fields.some(f => f.name === name)) return;
|
||||
const newField: TemplateField = this.newFieldType === 'IMAGE'
|
||||
? { name, type: 'IMAGE', layout: 'GALLERY' }
|
||||
: { name, type: 'TEXT' };
|
||||
this.fields = [...this.fields, newField];
|
||||
this.fields = [...this.fields, buildLoreTemplateField(name, this.newFieldType)];
|
||||
this.newFieldName = '';
|
||||
}
|
||||
|
||||
@@ -138,17 +147,11 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
||||
this.fields = next;
|
||||
}
|
||||
|
||||
/** 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) => {
|
||||
if (i !== index) return f;
|
||||
return nextType === 'IMAGE'
|
||||
? { name: f.name, type: 'IMAGE', layout: f.layout ?? 'GALLERY' }
|
||||
: { name: f.name, type: 'TEXT' };
|
||||
});
|
||||
/** Change le type d'un champ existant (TEXT / IMAGE / KEY_VALUE_LIST). */
|
||||
setFieldType(index: number, type: FieldType): void {
|
||||
this.fields = this.fields.map((f, i) =>
|
||||
i === index ? buildLoreTemplateField(f.name, type, f) : f
|
||||
);
|
||||
}
|
||||
|
||||
/** Met a jour le layout d'un champ IMAGE. */
|
||||
@@ -158,6 +161,24 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Sous-editeur des libelles (KEY_VALUE_LIST) -------------------------
|
||||
// Mutation en place des labels : recreer le tableau de fields a chaque
|
||||
// frappe ferait perdre le focus de l'input en cours d'edition.
|
||||
|
||||
addLabel(field: TemplateField): void {
|
||||
field.labels = [...(field.labels ?? []), ''];
|
||||
}
|
||||
|
||||
updateLabel(field: TemplateField, labelIndex: number, value: string): void {
|
||||
if (!field.labels) return;
|
||||
field.labels[labelIndex] = value;
|
||||
}
|
||||
|
||||
removeLabel(field: TemplateField, labelIndex: number): void {
|
||||
if (!field.labels) return;
|
||||
field.labels = field.labels.filter((_, i) => i !== labelIndex);
|
||||
}
|
||||
|
||||
save(): void {
|
||||
if (this.form.invalid || !this.template) return;
|
||||
const raw = this.form.value;
|
||||
@@ -166,7 +187,7 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
||||
name: raw.name,
|
||||
description: raw.description,
|
||||
defaultNodeId: raw.defaultNodeId || null,
|
||||
fields: this.fields
|
||||
fields: cleanFieldLabels(this.fields)
|
||||
}).subscribe({
|
||||
next: () => this.router.navigate(['/lore', this.loreId]),
|
||||
error: () => console.error('Erreur lors de la sauvegarde du template')
|
||||
|
||||
Reference in New Issue
Block a user