Refonte du système JDR + système de personnage joueurs / non joueurs :
Some checks failed
E2E Tests / e2e (push) Failing after 21s

- Système de templating dans le game system : en effet, les templates sont liés au game system car les fiches personnages ne sont pas forcément les même selon les jeux (perso Dnd possède + de compétences que Nimble par exemple)
- changement des fiches personnages pour adapter le templating au niveau des campagnes et remplir des pages de perso
This commit is contained in:
2026-04-30 10:42:09 +02:00
parent efaf5a3794
commit 52e389db24
67 changed files with 1610 additions and 255 deletions

View File

@@ -0,0 +1,42 @@
<div class="dff" *ngIf="fields?.length; else emptyTpl">
<div class="dff-field" *ngFor="let f of fields; trackBy: trackByName">
<label>{{ f.name }}</label>
<ng-container [ngSwitch]="f.type">
<textarea
*ngSwitchCase="'TEXT'"
rows="4"
[ngModel]="values[f.name] || ''"
(ngModelChange)="onTextChange(f, $event)"
[name]="'val-' + f.name"
placeholder="Renseignez {{ f.name }}…">
</textarea>
<input
*ngSwitchCase="'NUMBER'"
type="number"
[ngModel]="values[f.name] || ''"
(ngModelChange)="onTextChange(f, $event)"
[name]="'val-' + f.name"
placeholder="0"
/>
<div *ngSwitchCase="'IMAGE'" class="image-mvp">
<input
type="text"
[ngModel]="imageCsvCache[f.name] || ''"
(ngModelChange)="onImageCsvChange(f, $event)"
[name]="'img-' + f.name"
placeholder="IDs d'images separes par des virgules (MVP)"
/>
<small class="hint">Layout : {{ f.layout || 'GALLERY' }}. Picker dedie a venir.</small>
</div>
</ng-container>
</div>
</div>
<ng-template #emptyTpl>
<div class="dff-empty">
Aucun champ defini dans le template de ce systeme. Editez le GameSystem pour ajouter des champs.
</div>
</ng-template>

View File

@@ -0,0 +1,50 @@
.dff {
display: flex;
flex-direction: column;
gap: 14px;
}
.dff-field {
display: flex;
flex-direction: column;
gap: 6px;
label {
font-size: 0.9rem;
font-weight: 600;
color: var(--color-text, #fff);
}
input,
textarea {
padding: 8px 10px;
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
color: var(--color-text, #fff);
font-size: 0.95rem;
font-family: inherit;
resize: vertical;
}
}
.image-mvp {
display: flex;
flex-direction: column;
gap: 4px;
.hint {
font-size: 0.75rem;
color: var(--color-text-muted, #888);
font-style: italic;
}
}
.dff-empty {
padding: 24px;
text-align: center;
color: var(--color-text-muted, #888);
background: rgba(255, 255, 255, 0.03);
border: 1px dashed rgba(255, 255, 255, 0.1);
border-radius: 6px;
}

View File

@@ -0,0 +1,62 @@
import { Component, EventEmitter, Input, Output, OnChanges, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TemplateField } from '../../services/template-field.model';
/**
* Formulaire dynamique pilote par une liste de TemplateField.
*
* Inputs :
* - fields : structure (provient du GameSystem.characterTemplate / npcTemplate)
* - values : Record<champName, string> pour les types TEXT et NUMBER
* - imageValues : Record<champName, string[]> pour le type IMAGE
*
* Emet `valuesChange` et `imageValuesChange` a chaque modification.
*
* Pour les champs IMAGE le MVP affiche une simple textarea CSV d'IDs (le picker
* d'images dedie sera branche plus tard, hors scope de la refonte template).
*/
@Component({
selector: 'app-dynamic-fields-form',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './dynamic-fields-form.component.html',
styleUrls: ['./dynamic-fields-form.component.scss']
})
export class DynamicFieldsFormComponent implements OnChanges {
@Input() fields: TemplateField[] = [];
@Input() values: Record<string, string> = {};
@Input() imageValues: Record<string, string[]> = {};
@Output() valuesChange = new EventEmitter<Record<string, string>>();
@Output() imageValuesChange = new EventEmitter<Record<string, string[]>>();
/** Cache de strings CSV pour edition d'imageValues sans serialisation continue. */
imageCsvCache: Record<string, string> = {};
ngOnChanges(changes: SimpleChanges): void {
if (changes['imageValues'] || changes['fields']) {
this.imageCsvCache = {};
for (const f of this.fields) {
if (f.type === 'IMAGE') {
const list = this.imageValues[f.name] ?? [];
this.imageCsvCache[f.name] = list.join(', ');
}
}
}
}
onTextChange(field: TemplateField, value: string): void {
this.values = { ...this.values, [field.name]: value };
this.valuesChange.emit(this.values);
}
onImageCsvChange(field: TemplateField, csv: string): void {
this.imageCsvCache[field.name] = csv;
const ids = csv.split(',').map(s => s.trim()).filter(s => s.length > 0);
this.imageValues = { ...this.imageValues, [field.name]: ids };
this.imageValuesChange.emit(this.imageValues);
}
trackByName = (_: number, f: TemplateField) => f.name;
}

View File

@@ -0,0 +1,79 @@
<div class="tfe">
<div class="tfe-header">
<h3 class="tfe-label">{{ label }}</h3>
<p *ngIf="hint" class="tfe-hint">{{ hint }}</p>
</div>
<div class="tfe-list">
<div class="tfe-row" *ngFor="let f of fields; let i = index" [class.invalid]="isDuplicate(f, i) || !f.name.trim()">
<div class="tfe-row-controls">
<button type="button" class="btn-arrow" (click)="moveUp(i)" [disabled]="i === 0" title="Monter">
<lucide-icon [img]="ArrowUp" [size]="14"></lucide-icon>
</button>
<button type="button" class="btn-arrow" (click)="moveDown(i)" [disabled]="i === fields.length - 1" title="Descendre">
<lucide-icon [img]="ArrowDown" [size]="14"></lucide-icon>
</button>
</div>
<input
type="text"
class="tfe-name"
[(ngModel)]="f.name"
[name]="'name-' + i"
(ngModelChange)="onFieldChanged()"
placeholder="Nom du champ (ex: Histoire, PV...)"
/>
<select
class="tfe-type"
[(ngModel)]="f.type"
[name]="'type-' + i"
(ngModelChange)="onFieldChanged()">
<option *ngFor="let opt of typeOptions" [value]="opt.value">{{ opt.label }}</option>
</select>
<select
class="tfe-layout"
*ngIf="f.type === 'IMAGE'"
[(ngModel)]="f.layout"
[name]="'layout-' + i"
(ngModelChange)="onFieldChanged()">
<option *ngFor="let opt of layoutOptions" [value]="opt.value">{{ opt.label }}</option>
</select>
<button type="button" class="btn-remove" (click)="remove(i)" title="Supprimer ce champ">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</div>
<div *ngIf="fields.length === 0" class="tfe-empty">
Aucun champ pour l'instant — ajoutez-en avec les boutons ci-dessous.
</div>
</div>
<div class="tfe-add">
<span class="tfe-add-label">Ajouter :</span>
<button
type="button"
class="chip"
*ngFor="let s of suggestions"
[class.disabled]="isSuggestionUsed(s)"
[disabled]="isSuggestionUsed(s)"
(click)="addSuggestion(s)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon>
{{ s }}
</button>
<button type="button" class="chip chip-custom" (click)="addBlank('TEXT')">
<lucide-icon [img]="Type" [size]="12"></lucide-icon>
Texte
</button>
<button type="button" class="chip chip-custom" (click)="addBlank('NUMBER')">
<lucide-icon [img]="Hash" [size]="12"></lucide-icon>
Nombre
</button>
<button type="button" class="chip chip-custom" (click)="addBlank('IMAGE')">
<lucide-icon [img]="ImageIcon" [size]="12"></lucide-icon>
Image(s)
</button>
</div>
</div>

View File

@@ -0,0 +1,139 @@
.tfe {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
}
.tfe-header {
.tfe-label {
margin: 0 0 4px;
font-size: 1rem;
font-weight: 600;
color: var(--color-text, #fff);
}
.tfe-hint {
margin: 0;
font-size: 0.85rem;
color: var(--color-text-muted, #aaa);
}
}
.tfe-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.tfe-row {
display: grid;
grid-template-columns: auto 1fr 130px auto auto;
gap: 8px;
align-items: center;
padding: 6px 8px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 6px;
transition: border-color 120ms;
&.invalid {
border-color: rgba(255, 100, 100, 0.5);
}
}
.tfe-row-controls {
display: flex;
flex-direction: column;
gap: 2px;
}
.btn-arrow,
.btn-remove {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
color: var(--color-text-muted, #aaa);
cursor: pointer;
transition: all 120ms;
&:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
color: var(--color-text, #fff);
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
}
.btn-remove:hover {
border-color: rgba(255, 100, 100, 0.4);
color: rgba(255, 100, 100, 0.9);
}
.tfe-name,
.tfe-type,
.tfe-layout {
padding: 6px 8px;
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
color: var(--color-text, #fff);
font-size: 0.9rem;
}
.tfe-empty {
padding: 12px;
text-align: center;
font-size: 0.85rem;
font-style: italic;
color: var(--color-text-muted, #888);
}
.tfe-add {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
.tfe-add-label {
font-size: 0.85rem;
color: var(--color-text-muted, #aaa);
margin-right: 4px;
}
}
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
color: var(--color-text-muted, #ccc);
font-size: 0.8rem;
cursor: pointer;
transition: all 120ms;
&:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
color: var(--color-text, #fff);
}
&.disabled,
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.chip-custom {
border-style: dashed;
}

View File

@@ -0,0 +1,115 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Plus, Trash2, ArrowUp, ArrowDown, Type, Image as ImageIcon, Hash } from 'lucide-angular';
import { TemplateField, FieldType, ImageLayout } from '../../services/template-field.model';
/**
* Editeur reutilisable d'une liste de TemplateField.
* Pilote l'ajout / suppression / reordonnancement / changement de type / renommage.
*
* Emet `fieldsChange` a chaque modification pour permettre un binding 2-way :
* <app-template-fields-editor [fields]="myFields" (fieldsChange)="myFields = $event">
*
* Validation locale : duplicats de noms (case-insensitive) marques visuellement,
* mais c'est le parent qui decide du blocage du submit.
*/
@Component({
selector: 'app-template-fields-editor',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule],
templateUrl: './template-fields-editor.component.html',
styleUrls: ['./template-fields-editor.component.scss']
})
export class TemplateFieldsEditorComponent {
readonly Plus = Plus;
readonly Trash2 = Trash2;
readonly ArrowUp = ArrowUp;
readonly ArrowDown = ArrowDown;
readonly Type = Type;
readonly ImageIcon = ImageIcon;
readonly Hash = Hash;
/** Liste des champs (binding parent). */
@Input() fields: TemplateField[] = [];
/** Suggestions de noms de champs (chips ajout rapide). */
@Input() suggestions: string[] = [];
/** Label de la section (ex: "Champs de la fiche PJ"). */
@Input() label = 'Champs du template';
/** Hint affichee sous le label. */
@Input() hint?: string;
@Output() fieldsChange = new EventEmitter<TemplateField[]>();
readonly typeOptions: { value: FieldType; label: string }[] = [
{ value: 'TEXT', label: 'Texte' },
{ value: 'NUMBER', label: 'Nombre' },
{ value: 'IMAGE', label: 'Image(s)' }
];
readonly layoutOptions: { value: ImageLayout; label: string }[] = [
{ value: 'GALLERY', label: 'Galerie' },
{ value: 'HERO', label: 'Bandeau' },
{ value: 'MASONRY', label: 'Mosaique' },
{ value: 'CAROUSEL', label: 'Carrousel' }
];
isDuplicate(field: TemplateField, index: number): boolean {
if (!field.name?.trim()) return false;
const lower = field.name.trim().toLowerCase();
return this.fields.some((f, i) => i !== index && f.name?.trim().toLowerCase() === lower);
}
isSuggestionUsed(name: string): boolean {
const lower = name.toLowerCase();
return this.fields.some(f => f.name?.trim().toLowerCase() === lower);
}
addSuggestion(name: string): void {
if (this.isSuggestionUsed(name)) return;
this.emit([...this.fields, { name, type: 'TEXT', layout: null }]);
}
addBlank(type: FieldType): void {
const layout: ImageLayout | null = type === 'IMAGE' ? 'GALLERY' : null;
this.emit([...this.fields, { name: '', type, layout }]);
}
remove(index: number): void {
const next = [...this.fields];
next.splice(index, 1);
this.emit(next);
}
moveUp(index: number): void {
if (index <= 0) return;
const next = [...this.fields];
[next[index - 1], next[index]] = [next[index], next[index - 1]];
this.emit(next);
}
moveDown(index: number): void {
if (index >= this.fields.length - 1) return;
const next = [...this.fields];
[next[index], next[index + 1]] = [next[index + 1], next[index]];
this.emit(next);
}
/** Notifie les changements internes (input/select sur un champ existant). */
onFieldChanged(): void {
// Quand le type passe a IMAGE, layout = GALLERY ; sinon null.
for (const f of this.fields) {
if (f.type === 'IMAGE' && !f.layout) f.layout = 'GALLERY';
if (f.type !== 'IMAGE') f.layout = null;
}
this.emit([...this.fields]);
}
private emit(fields: TemplateField[]): void {
this.fields = fields;
this.fieldsChange.emit(fields);
}
}