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....)
Passage V0.8.3
This commit is contained in:
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "loremind-web",
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "loremind-web",
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.3",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^17.0.0",
|
||||
"@angular/common": "^17.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "loremind-web",
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.3",
|
||||
"description": "LoreMind Frontend - Angular",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user