Mise en ligne de la version 0.2.0
All checks were successful
Build & Push Images / build (brain) (push) Successful in 46s
Build & Push Images / build (core) (push) Successful in 1m21s
Build & Push Images / build (web) (push) Successful in 1m25s

This commit is contained in:
2026-04-21 14:25:17 +02:00
parent ebee8e106b
commit ba8a503b3e
300 changed files with 35329 additions and 1 deletions

View File

@@ -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>

View 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; }
}

View 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();
}
}