Refonte de toute la partie fiche de personnage avec mise en place d'un nouveau bloc de liste d'attribut (pour tout ce qui sera statistiques, compétences etc....)
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled

Passage V0.8.3
This commit is contained in:
2026-04-30 15:53:38 +02:00
parent b0edfb5aab
commit a1c9e67013
43 changed files with 777 additions and 157 deletions

View File

@@ -61,8 +61,10 @@
[fields]="templateFields"
[values]="values"
[imageValues]="imageValues"
[keyValueValues]="keyValueValues"
(valuesChange)="values = $event"
(imageValuesChange)="imageValues = $event">
(imageValuesChange)="imageValues = $event"
(keyValueValuesChange)="keyValueValues = $event">
</app-dynamic-fields-form>
</div>

View File

@@ -53,6 +53,7 @@ export class CharacterEditComponent implements OnInit {
headerImageId: string | null = null;
values: Record<string, string> = {};
imageValues: Record<string, string[]> = {};
keyValueValues: Record<string, Record<string, string>> = {};
templateFields: TemplateField[] = [];
private order = 0;
@@ -81,6 +82,7 @@ export class CharacterEditComponent implements OnInit {
this.headerImageId = c.headerImageId ?? null;
this.values = c.values ?? {};
this.imageValues = c.imageValues ?? {};
this.keyValueValues = c.keyValueValues ?? {};
this.order = c.order ?? 0;
},
error: () => this.back()
@@ -112,6 +114,7 @@ export class CharacterEditComponent implements OnInit {
headerImageId: this.headerImageId,
values: this.values,
imageValues: this.imageValues,
keyValueValues: this.keyValueValues,
campaignId: this.campaignId
};
const req = this.characterId

View File

@@ -61,8 +61,10 @@
[fields]="templateFields"
[values]="values"
[imageValues]="imageValues"
[keyValueValues]="keyValueValues"
(valuesChange)="values = $event"
(imageValuesChange)="imageValues = $event">
(imageValuesChange)="imageValues = $event"
(keyValueValuesChange)="keyValueValues = $event">
</app-dynamic-fields-form>
</div>

View File

@@ -48,6 +48,7 @@ export class NpcEditComponent implements OnInit {
headerImageId: string | null = null;
values: Record<string, string> = {};
imageValues: Record<string, string[]> = {};
keyValueValues: Record<string, Record<string, string>> = {};
templateFields: TemplateField[] = [];
private order = 0;
@@ -76,6 +77,7 @@ export class NpcEditComponent implements OnInit {
this.headerImageId = n.headerImageId ?? null;
this.values = n.values ?? {};
this.imageValues = n.imageValues ?? {};
this.keyValueValues = n.keyValueValues ?? {};
this.order = n.order ?? 0;
},
error: () => this.back()
@@ -107,6 +109,7 @@ export class NpcEditComponent implements OnInit {
headerImageId: this.headerImageId,
values: this.values,
imageValues: this.imageValues,
keyValueValues: this.keyValueValues,
campaignId: this.campaignId
};
const req = this.npcId

View File

@@ -13,6 +13,8 @@ export interface Character {
headerImageId?: string | null;
values?: Record<string, string>;
imageValues?: Record<string, string[]>;
/** Champs KEY_VALUE_LIST : fieldName -> label -> value. */
keyValueValues?: Record<string, Record<string, string>>;
campaignId: string;
order?: number;
}
@@ -23,5 +25,6 @@ export interface CharacterCreate {
headerImageId?: string | null;
values?: Record<string, string>;
imageValues?: Record<string, string[]>;
keyValueValues?: Record<string, Record<string, string>>;
campaignId: string;
}

View File

@@ -9,6 +9,7 @@ export interface Npc {
headerImageId?: string | null;
values?: Record<string, string>;
imageValues?: Record<string, string[]>;
keyValueValues?: Record<string, Record<string, string>>;
campaignId: string;
order?: number;
}
@@ -19,5 +20,6 @@ export interface NpcCreate {
headerImageId?: string | null;
values?: Record<string, string>;
imageValues?: Record<string, string[]>;
keyValueValues?: Record<string, Record<string, string>>;
campaignId: string;
}

View File

@@ -2,11 +2,12 @@
/**
* Type d'un champ de Template. Miroir de com.loremind.domain.shared.template.FieldType.
* - 'TEXT' : champ textuel libre (rendu en textarea)
* - 'IMAGE' : galerie d'images (rendu en app-image-gallery)
* - 'NUMBER' : valeur numerique (rendu en input number)
* - 'TEXT' : champ textuel libre (rendu en textarea)
* - 'IMAGE' : galerie d'images (rendu en app-image-gallery)
* - 'NUMBER' : valeur numerique (rendu en input number)
* - 'KEY_VALUE_LIST' : liste de paires {label, value} avec labels figes au template
*/
export type FieldType = 'TEXT' | 'IMAGE' | 'NUMBER';
export type FieldType = 'TEXT' | 'IMAGE' | 'NUMBER' | 'KEY_VALUE_LIST';
/**
* Variante de rendu pour un champ IMAGE. Miroir de
@@ -27,6 +28,8 @@ export interface TemplateField {
type: FieldType;
/** Uniquement pour type='IMAGE'. Absent/null = 'GALLERY'. */
layout?: ImageLayout | null;
/** Labels predefinis pour KEY_VALUE_LIST (ordre significatif). */
labels?: string[] | null;
}
export interface Template {

View File

@@ -28,6 +28,23 @@
[imageIds]="imagesFor(f)"
(imageIdsChange)="onImageIdsChange(f, $event)">
</app-image-gallery>
<!-- KEY_VALUE_LIST : grille d'inputs avec labels figes du template -->
<div *ngSwitchCase="'KEY_VALUE_LIST'" class="dff-kv-grid">
<div class="dff-kv-cell" *ngFor="let lbl of f.labels; trackBy: trackByLabel">
<span class="dff-kv-label">{{ lbl }}</span>
<input
type="text"
[ngModel]="kvValue(f, lbl)"
(ngModelChange)="onKvChange(f, lbl, $event)"
[name]="'kv-' + f.name + '-' + lbl"
placeholder="—"
/>
</div>
<div *ngIf="!f.labels?.length" class="dff-kv-empty">
Aucun label defini dans le template pour ce champ.
</div>
</div>
</ng-container>
</div>
</div>

View File

@@ -40,6 +40,50 @@
}
}
.dff-kv-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 8px;
}
.dff-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;
.dff-kv-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted, #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: var(--color-text, #fff);
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
font-weight: 700;
font-size: 1.05rem;
}
}
.dff-kv-empty {
padding: 8px;
font-size: 0.8rem;
color: var(--color-text-muted, #888);
font-style: italic;
}
.dff-empty {
padding: 24px;
text-align: center;

View File

@@ -26,9 +26,11 @@ export class DynamicFieldsFormComponent {
@Input() fields: TemplateField[] = [];
@Input() values: Record<string, string> = {};
@Input() imageValues: Record<string, string[]> = {};
@Input() keyValueValues: Record<string, Record<string, string>> = {};
@Output() valuesChange = new EventEmitter<Record<string, string>>();
@Output() imageValuesChange = new EventEmitter<Record<string, string[]>>();
@Output() keyValueValuesChange = new EventEmitter<Record<string, Record<string, string>>>();
onTextChange(field: TemplateField, value: string): void {
this.values = { ...this.values, [field.name]: value };
@@ -44,5 +46,18 @@ export class DynamicFieldsFormComponent {
return this.imageValues[field.name] ?? [];
}
/** Valeur d'un label particulier dans un champ KEY_VALUE_LIST. */
kvValue(field: TemplateField, label: string): string {
return this.keyValueValues?.[field.name]?.[label] ?? '';
}
/** Met a jour la valeur d'un label dans un champ KEY_VALUE_LIST. */
onKvChange(field: TemplateField, label: string, value: string): void {
const inner = { ...(this.keyValueValues[field.name] ?? {}), [label]: value };
this.keyValueValues = { ...this.keyValueValues, [field.name]: inner };
this.keyValueValuesChange.emit(this.keyValueValues);
}
trackByName = (_: number, f: TemplateField) => f.name;
trackByLabel = (_: number, l: string) => l;
}

View File

@@ -15,44 +15,92 @@
<div class="pv-title-block">
<h1 class="pv-name">{{ persona.name }}</h1>
<p *ngIf="subtitle" class="pv-subtitle">{{ subtitle }}</p>
<!-- Badges des NUMBER isoles (rendu compact, evite la grosse card pour 1 valeur) -->
<div *ngIf="heroBadges.length" class="pv-hero-badges">
<span *ngFor="let b of heroBadges" class="pv-hero-badge">
<span class="pv-hero-badge-label">{{ b.label }}</span>
<span class="pv-hero-badge-value">{{ b.value }}</span>
</span>
</div>
</div>
</div>
<!-- Stats numeriques en bandeau si presentes -->
<div *ngIf="textFields.length && hasAnyNumber(textFields)" class="pv-stat-band">
<div *ngFor="let f of textFields" class="pv-stat" [class.pv-stat-number]="f.isNumber">
<ng-container *ngIf="f.isNumber">
<span class="pv-stat-label">{{ f.name }}</span>
<span class="pv-stat-value">{{ f.value }}</span>
</ng-container>
</div>
</div>
<!-- Sections texte -->
<!-- Sections rendues dans l'ordre du template -->
<div class="pv-sections">
<ng-container *ngFor="let f of textFields; let first = first">
<section *ngIf="!f.isNumber" class="pv-section">
<h2 class="pv-section-title">{{ f.name }}</h2>
<ng-container *ngFor="let s of orderedSections">
<!-- TEXT -->
<section *ngIf="s.kind === 'TEXT'" class="pv-section">
<h2 class="pv-section-title">{{ s.name }}</h2>
<div class="pv-section-body">
<p [class.with-dropcap]="first" class="pv-paragraph">
{{ firstParagraph(f.value) }}
<p [class.with-dropcap]="s.name === firstTextSectionName" class="pv-paragraph">
{{ firstParagraph(s.value) }}
</p>
<p *ngIf="restAfterFirstParagraph(f.value)" class="pv-paragraph">
{{ restAfterFirstParagraph(f.value) }}
<p *ngIf="restAfterFirstParagraph(s.value)" class="pv-paragraph">
{{ restAfterFirstParagraph(s.value) }}
</p>
</div>
</section>
<!-- NUMBER_GROUP : NUMBER consecutifs - table si <=6, sinon liste 2 cols -->
<section *ngIf="s.kind === 'NUMBER_GROUP'" class="pv-section pv-section-number">
<ng-container *ngIf="s.entries.length <= 6; else numGroupList">
<div class="pv-kv-table" [style.--cols]="s.entries.length">
<div class="pv-kv-row pv-kv-row-labels">
<div class="pv-kv-cell" *ngFor="let e of s.entries">{{ e.label }}</div>
</div>
<div class="pv-kv-row pv-kv-row-values">
<div class="pv-kv-cell" *ngFor="let e of s.entries">{{ e.value }}</div>
</div>
</div>
</ng-container>
<ng-template #numGroupList>
<div class="pv-kv-list">
<div class="pv-kv-list-row" *ngFor="let e of s.entries">
<span class="pv-kv-list-label">{{ e.label }}</span>
<span class="pv-kv-list-dots"></span>
<span class="pv-kv-list-value">{{ e.value }}</span>
</div>
</div>
</ng-template>
</section>
<!-- KEY_VALUE_LIST : table style Foundry si <=6, sinon liste 2 cols (skills) -->
<section *ngIf="s.kind === 'KEY_VALUE_LIST'" class="pv-section pv-section-kv">
<h2 class="pv-section-title">{{ s.name }}</h2>
<ng-container *ngIf="s.entries.length <= 6; else kvList">
<div class="pv-kv-table" [style.--cols]="s.entries.length">
<div class="pv-kv-row pv-kv-row-labels">
<div class="pv-kv-cell" *ngFor="let e of s.entries">{{ e.label }}</div>
</div>
<div class="pv-kv-row pv-kv-row-values">
<div class="pv-kv-cell" *ngFor="let e of s.entries">{{ e.value || '—' }}</div>
</div>
</div>
</ng-container>
<ng-template #kvList>
<div class="pv-kv-list">
<div class="pv-kv-list-row" *ngFor="let e of s.entries">
<span class="pv-kv-list-label">{{ e.label }}</span>
<span class="pv-kv-list-dots"></span>
<span class="pv-kv-list-value">{{ e.value || '—' }}</span>
</div>
</div>
</ng-template>
</section>
<!-- IMAGE : galerie -->
<section *ngIf="s.kind === 'IMAGE'" class="pv-section pv-section-images">
<h2 class="pv-section-title">{{ s.name }}</h2>
<app-image-gallery [imageIds]="s.ids" [layout]="s.layout" [editable]="false">
</app-image-gallery>
</section>
</ng-container>
<!-- Galeries d'images templates -->
<section *ngFor="let img of imageFields" class="pv-section pv-section-images">
<h2 class="pv-section-title">{{ img.field.name }}</h2>
<app-image-gallery [imageIds]="img.ids" [layout]="img.field.layout || 'GALLERY'" [editable]="false">
</app-image-gallery>
</section>
<!-- Etat vide -->
<div *ngIf="textFields.length === 0 && imageFields.length === 0" class="pv-empty">
<div *ngIf="orderedSections.length === 0" class="pv-empty">
<lucide-icon [img]="BookOpen" [size]="32"></lucide-icon>
<p>Cette fiche est encore vide.</p>
</div>

View File

@@ -92,41 +92,145 @@
letter-spacing: 0.05em;
}
// --- Bandeau de stats (NUMBER) ---------------------------------------------
.pv-stat-band {
// Badges compacts pour les NUMBER isoles (Niveau, etc.) — evite la grosse card.
.pv-hero-badges {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 16px;
padding: 12px 32px;
margin: 16px 32px 0;
background: rgba(255, 255, 255, 0.04);
border-top: 1px solid rgba(255, 255, 255, 0.08);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
gap: 8px;
}
.pv-stat {
display: none;
.pv-hero-badge {
display: inline-flex;
align-items: baseline;
gap: 6px;
padding: 4px 12px;
background: rgba(209, 168, 120, 0.08);
border: 1px solid rgba(209, 168, 120, 0.3);
border-radius: 100px;
&.pv-stat-number {
display: flex;
flex-direction: column;
align-items: center;
min-width: 60px;
}
.pv-stat-label {
.pv-hero-badge-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
letter-spacing: 0.1em;
color: #9ca3af;
}
.pv-stat-value {
font-size: 1.25rem;
.pv-hero-badge-value {
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
font-weight: 700;
color: #f3f4f6;
font-family: 'Cinzel', Georgia, serif;
font-size: 1.05rem;
color: #d1a878;
}
}
// --- Table compacte (NUMBER_GROUP / KEY_VALUE_LIST) ------------------------
// Style Foundry : 2 rangees (labels uppercase / valeurs serif), separateurs
// fins, sans gros ornements. --cols est la variable CSS pour le nombre
// d'entrees, posee inline dans le HTML.
.pv-kv-table {
display: flex;
flex-direction: column;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 4px;
overflow: hidden;
background: rgba(0, 0, 0, 0.18);
}
.pv-kv-row {
display: grid;
grid-template-columns: repeat(var(--cols, 4), minmax(0, 1fr));
}
.pv-kv-row-labels {
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
.pv-kv-cell {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #9ca3af;
padding: 6px 4px;
text-align: center;
}
}
.pv-kv-row-values {
.pv-kv-cell {
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
font-size: 1.15rem;
font-weight: 700;
color: #d1a878;
padding: 8px 4px;
text-align: center;
}
}
.pv-kv-cell {
border-right: 1px solid rgba(255, 255, 255, 0.04);
&:last-child {
border-right: none;
}
}
@media (max-width: 600px) {
// Sur mobile : si plus de 4 colonnes, on retombe en wrap (sacrifice de la
// structure tableau pour eviter le scroll horizontal sur les stats blocks).
.pv-kv-row {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
// --- Liste 2 colonnes (KV avec >6 entrees, type "skills" Foundry) ---------
.pv-kv-list {
column-count: 2;
column-gap: 32px;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.18);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 4px;
}
.pv-kv-list-row {
display: flex;
align-items: baseline;
gap: 6px;
padding: 4px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
break-inside: avoid; // empeche un row de se couper entre 2 colonnes
&:last-child {
border-bottom: none;
}
.pv-kv-list-label {
font-size: 0.85rem;
color: #d6d8de;
flex-shrink: 0;
}
// Pointilles entre label et valeur (style fiche papier)
.pv-kv-list-dots {
flex: 1;
border-bottom: 1px dotted rgba(255, 255, 255, 0.12);
transform: translateY(-3px);
}
.pv-kv-list-value {
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
font-weight: 700;
font-size: 1rem;
color: #d1a878;
flex-shrink: 0;
}
}
@media (max-width: 600px) {
.pv-kv-list {
column-count: 1;
}
}
@@ -144,6 +248,13 @@
}
}
.pv-section-kv,
.pv-section-number {
.pv-kv-table {
margin-top: 8px;
}
}
.pv-section-title {
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
font-size: 1.4rem;

View File

@@ -1,7 +1,14 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, BookOpen } from 'lucide-angular';
import { TemplateField } from '../../services/template.model';
import { TemplateField, ImageLayout } from '../../services/template.model';
/** Section rendue dans la vue, dans l'ordre du template. Discriminee par `kind`. */
export type RenderedSection =
| { kind: 'TEXT'; name: string; value: string }
| { kind: 'NUMBER_GROUP'; entries: { label: string; value: string }[] }
| { kind: 'KEY_VALUE_LIST'; name: string; entries: { label: string; value: string }[] }
| { kind: 'IMAGE'; name: string; ids: string[]; layout: ImageLayout };
import { ImageService } from '../../services/image.service';
import { ImageGalleryComponent } from '../image-gallery/image-gallery.component';
@@ -22,6 +29,7 @@ export interface PersonaLike {
headerImageId?: string | null;
values?: Record<string, string>;
imageValues?: Record<string, string[]>;
keyValueValues?: Record<string, Record<string, string>>;
}
@Component({
@@ -46,30 +54,69 @@ export class PersonaViewComponent {
return this.imageService.contentUrl(id);
}
/** Champs TEXT/NUMBER non vides, dans l'ordre du template. */
get textFields(): { name: string; value: string; isNumber: boolean }[] {
if (!this.persona?.values) return [];
return this.templateFields
.filter(f => (f.type === 'TEXT' || f.type === 'NUMBER'))
.map(f => ({
name: f.name,
value: this.persona!.values?.[f.name] ?? '',
isNumber: f.type === 'NUMBER'
}))
.filter(x => x.value && x.value.trim().length > 0);
/**
* Decompose la fiche en (heroBadges, sections) en un seul passage :
* - NUMBER consecutifs groupes : 2+ → NUMBER_GROUP en section ; 1 isole → badge hero.
* - TEXT / KEY_VALUE_LIST / IMAGE : sections dans l'ordre du template.
* Le calcul est fait par rendered() et cache via le get pour eviter les
* recalculs multiples par cycle de change detection.
*/
private rendered(): { heroBadges: { label: string; value: string }[]; sections: RenderedSection[] } {
const sections: RenderedSection[] = [];
const heroBadges: { label: string; value: string }[] = [];
let numberBuffer: { label: string; value: string }[] = [];
const flushNumberBuffer = () => {
if (numberBuffer.length === 1) {
heroBadges.push(numberBuffer[0]);
} else if (numberBuffer.length > 1) {
sections.push({ kind: 'NUMBER_GROUP', entries: numberBuffer });
}
numberBuffer = [];
};
for (const f of this.templateFields) {
if (f.type === 'NUMBER') {
const value = this.persona?.values?.[f.name] ?? '';
if (value.trim()) numberBuffer.push({ label: f.name, value });
continue;
}
flushNumberBuffer();
if (f.type === 'TEXT') {
const value = this.persona?.values?.[f.name] ?? '';
if (value.trim()) sections.push({ kind: 'TEXT', name: f.name, value });
} else if (f.type === 'KEY_VALUE_LIST') {
const inner = this.persona?.keyValueValues?.[f.name] ?? {};
const labels = f.labels ?? [];
const entries = labels.map(label => ({ label, value: inner[label] ?? '' }));
if (entries.some(e => e.value.trim())) {
sections.push({ kind: 'KEY_VALUE_LIST', name: f.name, entries });
}
} else if (f.type === 'IMAGE') {
const ids = this.persona?.imageValues?.[f.name] ?? [];
if (ids.length > 0) {
sections.push({ kind: 'IMAGE', name: f.name, ids, layout: f.layout ?? 'GALLERY' });
}
}
}
flushNumberBuffer();
return { heroBadges, sections };
}
/** Champs IMAGE non vides, dans l'ordre du template. */
get imageFields(): { field: TemplateField; ids: string[] }[] {
if (!this.persona?.imageValues) return [];
return this.templateFields
.filter(f => f.type === 'IMAGE')
.map(f => ({ field: f, ids: this.persona!.imageValues?.[f.name] ?? [] }))
.filter(x => x.ids.length > 0);
get heroBadges(): { label: string; value: string }[] {
return this.rendered().heroBadges;
}
hasAnyNumber(fields: { isNumber: boolean }[]): boolean {
return fields.some(f => f.isNumber);
get orderedSections(): RenderedSection[] {
return this.rendered().sections;
}
/** Pour la drop cap : seul le 1er TEXT la recoit. */
get firstTextSectionName(): string | null {
for (const s of this.orderedSections) {
if (s.kind === 'TEXT') return s.name;
}
return null;
}
/** Premier paragraphe d'un texte (utilise pour la drop cap). */

View File

@@ -5,45 +5,80 @@
</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>
<div class="tfe-item" *ngFor="let f of fields; let i = index">
<div class="tfe-row" [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>
<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>
<!-- Sous-editeur des labels pour KEY_VALUE_LIST -->
<div class="tfe-labels" *ngIf="f.type === 'KEY_VALUE_LIST'">
<div class="tfe-labels-header">
<lucide-icon [img]="ListOrdered" [size]="12"></lucide-icon>
<span>Labels (cles fixes pour toutes les fiches)</span>
</div>
<div class="tfe-labels-list">
<div class="tfe-label-row" *ngFor="let lbl of f.labels; let li = index; trackBy: trackByIndex">
<button type="button" class="btn-arrow-mini" (click)="moveLabelUp(f, li)" [disabled]="li === 0" title="Monter">
<lucide-icon [img]="ArrowUp" [size]="11"></lucide-icon>
</button>
<button type="button" class="btn-arrow-mini" (click)="moveLabelDown(f, li)" [disabled]="li === (f.labels?.length || 0) - 1" title="Descendre">
<lucide-icon [img]="ArrowDown" [size]="11"></lucide-icon>
</button>
<input
type="text"
class="tfe-label-input"
[ngModel]="lbl"
(ngModelChange)="updateLabelAt(f, li, $event)"
[name]="'lbl-' + i + '-' + li"
placeholder="Ex: FOR, DEX..."
/>
<button type="button" class="btn-remove-mini" (click)="removeLabel(f, li)" title="Retirer ce label">
<lucide-icon [img]="X" [size]="11"></lucide-icon>
</button>
</div>
</div>
<button type="button" class="chip chip-mini" (click)="addLabel(f)">
<lucide-icon [img]="Plus" [size]="11"></lucide-icon>
Ajouter un label
</button>
</div>
</div>
<div *ngIf="fields.length === 0" class="tfe-empty">
@@ -75,5 +110,9 @@
<lucide-icon [img]="ImageIcon" [size]="12"></lucide-icon>
Image(s)
</button>
<button type="button" class="chip chip-custom" (click)="addBlank('KEY_VALUE_LIST')">
<lucide-icon [img]="ListOrdered" [size]="12"></lucide-icon>
Liste cle/valeur
</button>
</div>
</div>

View File

@@ -137,3 +137,88 @@
.chip-custom {
border-style: dashed;
}
.chip-mini {
padding: 3px 8px;
font-size: 0.72rem;
align-self: flex-start;
}
// --- Sous-editeur labels (KEY_VALUE_LIST) ---
.tfe-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.tfe-labels {
margin-left: 32px;
padding: 8px 10px;
background: rgba(0, 0, 0, 0.15);
border: 1px dashed rgba(255, 255, 255, 0.08);
border-radius: 4px;
display: flex;
flex-direction: column;
gap: 6px;
}
.tfe-labels-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.75rem;
color: var(--color-text-muted, #aaa);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.tfe-labels-list {
display: flex;
flex-direction: column;
gap: 3px;
}
.tfe-label-row {
display: grid;
grid-template-columns: auto auto 1fr auto;
gap: 4px;
align-items: center;
}
.btn-arrow-mini,
.btn-remove-mini {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 3px;
color: var(--color-text-muted, #888);
cursor: pointer;
transition: all 100ms;
&:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.06);
color: var(--color-text, #fff);
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
}
.btn-remove-mini:hover {
border-color: rgba(255, 100, 100, 0.4);
color: rgba(255, 100, 100, 0.9);
}
.tfe-label-input {
padding: 4px 6px;
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 3px;
color: var(--color-text, #fff);
font-size: 0.8rem;
}

View File

@@ -1,7 +1,7 @@
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 { LucideAngularModule, Plus, Trash2, ArrowUp, ArrowDown, Type, Image as ImageIcon, Hash, ListOrdered, X } from 'lucide-angular';
import { TemplateField, FieldType, ImageLayout } from '../../services/template.model';
/**
@@ -29,6 +29,8 @@ export class TemplateFieldsEditorComponent {
readonly Type = Type;
readonly ImageIcon = ImageIcon;
readonly Hash = Hash;
readonly ListOrdered = ListOrdered;
readonly X = X;
/** Liste des champs (binding parent). */
@Input() fields: TemplateField[] = [];
@@ -47,7 +49,8 @@ export class TemplateFieldsEditorComponent {
readonly typeOptions: { value: FieldType; label: string }[] = [
{ value: 'TEXT', label: 'Texte' },
{ value: 'NUMBER', label: 'Nombre' },
{ value: 'IMAGE', label: 'Image(s)' }
{ value: 'IMAGE', label: 'Image(s)' },
{ value: 'KEY_VALUE_LIST', label: 'Liste cle/valeur' }
];
readonly layoutOptions: { value: ImageLayout; label: string }[] = [
@@ -75,9 +78,53 @@ export class TemplateFieldsEditorComponent {
addBlank(type: FieldType): void {
const layout: ImageLayout | null = type === 'IMAGE' ? 'GALLERY' : null;
this.emit([...this.fields, { name: '', type, layout }]);
const labels: string[] | null = type === 'KEY_VALUE_LIST' ? [] : null;
this.emit([...this.fields, { name: '', type, layout, labels }]);
}
// --- Sous-editeur labels (KEY_VALUE_LIST) ---
addLabel(field: TemplateField): void {
const labels = field.labels ? [...field.labels] : [];
labels.push('');
field.labels = labels;
this.onFieldChanged();
}
removeLabel(field: TemplateField, index: number): void {
if (!field.labels) return;
const labels = [...field.labels];
labels.splice(index, 1);
field.labels = labels;
this.onFieldChanged();
}
updateLabelAt(field: TemplateField, index: number, value: string): void {
if (!field.labels) return;
const labels = [...field.labels];
labels[index] = value;
field.labels = labels;
this.onFieldChanged();
}
moveLabelUp(field: TemplateField, index: number): void {
if (!field.labels || index <= 0) return;
const labels = [...field.labels];
[labels[index - 1], labels[index]] = [labels[index], labels[index - 1]];
field.labels = labels;
this.onFieldChanged();
}
moveLabelDown(field: TemplateField, index: number): void {
if (!field.labels || index >= field.labels.length - 1) return;
const labels = [...field.labels];
[labels[index], labels[index + 1]] = [labels[index + 1], labels[index]];
field.labels = labels;
this.onFieldChanged();
}
trackByIndex = (i: number) => i;
remove(index: number): void {
const next = [...this.fields];
next.splice(index, 1);
@@ -100,10 +147,11 @@ export class TemplateFieldsEditorComponent {
/** 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;
if (f.type === 'KEY_VALUE_LIST' && !f.labels) f.labels = [];
if (f.type !== 'KEY_VALUE_LIST') f.labels = null;
}
this.emit([...this.fields]);
}