Mise en place du picker d'image pour la partie header / illustration des fiches personnage
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m1s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build (web) (push) Successful in 1m42s

Migration pour l'ancienne partie des fiches perso vers les nouvelles pages
Vue retravaillée pour les fiches perso
This commit is contained in:
2026-04-30 10:54:27 +02:00
parent 52e389db24
commit 7c4a42327d
28 changed files with 1103 additions and 112 deletions

View File

@@ -21,16 +21,13 @@
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>
<app-image-gallery
*ngSwitchCase="'IMAGE'"
[editable]="true"
[layout]="f.layout || 'GALLERY'"
[imageIds]="imagesFor(f)"
(imageIdsChange)="onImageIdsChange(f, $event)">
</app-image-gallery>
</ng-container>
</div>
</div>

View File

@@ -1,7 +1,8 @@
import { Component, EventEmitter, Input, Output, OnChanges, SimpleChanges } from '@angular/core';
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TemplateField } from '../../services/template-field.model';
import { TemplateField } from '../../services/template.model';
import { ImageGalleryComponent } from '../image-gallery/image-gallery.component';
/**
* Formulaire dynamique pilote par une liste de TemplateField.
@@ -11,19 +12,17 @@ import { TemplateField } from '../../services/template-field.model';
* - 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).
* Pour les champs IMAGE, delegue au composant <app-image-gallery editable>
* qui gere l'upload, la suppression et le respect du layout.
*/
@Component({
selector: 'app-dynamic-fields-form',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [CommonModule, FormsModule, ImageGalleryComponent],
templateUrl: './dynamic-fields-form.component.html',
styleUrls: ['./dynamic-fields-form.component.scss']
})
export class DynamicFieldsFormComponent implements OnChanges {
export class DynamicFieldsFormComponent {
@Input() fields: TemplateField[] = [];
@Input() values: Record<string, string> = {};
@Input() imageValues: Record<string, string[]> = {};
@@ -31,32 +30,19 @@ export class DynamicFieldsFormComponent implements OnChanges {
@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);
onImageIdsChange(field: TemplateField, ids: string[]): void {
this.imageValues = { ...this.imageValues, [field.name]: ids };
this.imageValuesChange.emit(this.imageValues);
}
imagesFor(field: TemplateField): string[] {
return this.imageValues[field.name] ?? [];
}
trackByName = (_: number, f: TemplateField) => f.name;
}

View File

@@ -0,0 +1,60 @@
<div class="pv" *ngIf="persona">
<!-- Bandeau / Header -->
<div class="pv-banner" *ngIf="persona.headerImageId">
<img [src]="contentUrl(persona.headerImageId)" alt="" />
<div class="pv-banner-fade"></div>
</div>
<!-- En-tete : portrait + titre -->
<div class="pv-hero" [class.no-banner]="!persona.headerImageId">
<div class="pv-portrait" *ngIf="persona.portraitImageId">
<img [src]="contentUrl(persona.portraitImageId)" alt="" />
</div>
<div class="pv-title-block">
<h1 class="pv-name">{{ persona.name }}</h1>
<p *ngIf="subtitle" class="pv-subtitle">{{ subtitle }}</p>
</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 -->
<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>
<div class="pv-section-body">
<p [class.with-dropcap]="first" class="pv-paragraph">
{{ firstParagraph(f.value) }}
</p>
<p *ngIf="restAfterFirstParagraph(f.value)" class="pv-paragraph">
{{ restAfterFirstParagraph(f.value) }}
</p>
</div>
</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">
<lucide-icon [img]="BookOpen" [size]="32"></lucide-icon>
<p>Cette fiche est encore vide.</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,228 @@
// Vue WorldAnvil-style : bandeau, portrait latteral, sections elegantes, drop cap.
.pv {
max-width: 1100px;
margin: 0 auto;
color: #e5e7eb;
}
// --- Bandeau ----------------------------------------------------------------
.pv-banner {
position: relative;
height: 280px;
overflow: hidden;
border-radius: 8px 8px 0 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.pv-banner-fade {
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(
to bottom,
transparent 60%,
rgba(15, 17, 23, 0.85) 100%
);
}
// --- Hero (portrait + titre) ------------------------------------------------
.pv-hero {
display: grid;
grid-template-columns: 220px 1fr;
gap: 28px;
padding: 20px 32px;
margin-top: -90px;
position: relative;
z-index: 1;
&.no-banner {
margin-top: 0;
padding-top: 32px;
}
}
.pv-portrait {
width: 220px;
height: 220px;
border-radius: 6px;
overflow: hidden;
border: 3px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
background: #1a1d24;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.pv-title-block {
display: flex;
flex-direction: column;
justify-content: flex-end;
padding-bottom: 8px;
}
.pv-name {
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
font-size: 3rem;
font-weight: 600;
letter-spacing: 0.04em;
margin: 0;
color: #f3f4f6;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6);
}
.pv-subtitle {
margin: 8px 0 0;
font-size: 1.05rem;
font-style: italic;
color: #b5b9c4;
letter-spacing: 0.05em;
}
// --- Bandeau de stats (NUMBER) ---------------------------------------------
.pv-stat-band {
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);
}
.pv-stat {
display: none;
&.pv-stat-number {
display: flex;
flex-direction: column;
align-items: center;
min-width: 60px;
}
.pv-stat-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #9ca3af;
}
.pv-stat-value {
font-size: 1.25rem;
font-weight: 700;
color: #f3f4f6;
font-family: 'Cinzel', Georgia, serif;
}
}
// --- Sections ---------------------------------------------------------------
.pv-sections {
padding: 32px 32px 48px;
}
.pv-section {
margin-bottom: 36px;
&:last-child {
margin-bottom: 0;
}
}
.pv-section-title {
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
font-size: 1.4rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #d1a878;
margin: 0 0 12px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(209, 168, 120, 0.25);
position: relative;
// Petit ornement central
&::after {
content: '';
position: absolute;
left: 50%;
bottom: -3px;
width: 6px;
height: 6px;
background: #d1a878;
border-radius: 50%;
transform: translateX(-50%);
}
}
.pv-section-body {
font-size: 1rem;
line-height: 1.65;
color: #d6d8de;
}
.pv-paragraph {
margin: 0 0 14px;
white-space: pre-wrap;
&.with-dropcap::first-letter {
float: left;
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
font-size: 3.5rem;
line-height: 0.9;
font-weight: 700;
color: #d1a878;
padding: 4px 8px 0 0;
margin-top: 4px;
}
}
// --- Etat vide --------------------------------------------------------------
.pv-empty {
text-align: center;
padding: 64px 24px;
color: #6b7280;
font-style: italic;
p {
margin: 12px 0 0;
}
}
// --- Responsive -------------------------------------------------------------
@media (max-width: 720px) {
.pv-hero {
grid-template-columns: 1fr;
margin-top: -60px;
}
.pv-portrait {
width: 160px;
height: 160px;
}
.pv-name {
font-size: 2.2rem;
}
.pv-banner {
height: 180px;
}
}

View File

@@ -0,0 +1,88 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, BookOpen } from 'lucide-angular';
import { TemplateField } from '../../services/template.model';
import { ImageService } from '../../services/image.service';
import { ImageGalleryComponent } from '../image-gallery/image-gallery.component';
/**
* Affichage type "WorldAnvil" d'une fiche PJ ou PNJ.
*
* Layout :
* - Bandeau (headerImageId) en haut, pleine largeur
* - Bloc 2 colonnes : portrait a gauche, infos textuelles a droite
* - Sections suivantes pour chaque champ template TEXT/NUMBER/IMAGE
* - Drop cap sur la 1re lettre du 1er paragraphe TEXT
*
* Composant pur de presentation : ne fetche rien, recoit (persona, templateFields).
*/
export interface PersonaLike {
name: string;
portraitImageId?: string | null;
headerImageId?: string | null;
values?: Record<string, string>;
imageValues?: Record<string, string[]>;
}
@Component({
selector: 'app-persona-view',
standalone: true,
imports: [CommonModule, LucideAngularModule, ImageGalleryComponent],
templateUrl: './persona-view.component.html',
styleUrls: ['./persona-view.component.scss']
})
export class PersonaViewComponent {
readonly BookOpen = BookOpen;
@Input() persona: PersonaLike | null = null;
@Input() templateFields: TemplateField[] = [];
/** Sous-titre optionnel sous le nom (ex: "Champion d'Aerimor"). */
@Input() subtitle?: string;
constructor(private imageService: ImageService) {}
contentUrl(id: string): string {
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);
}
/** 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);
}
hasAnyNumber(fields: { isNumber: boolean }[]): boolean {
return fields.some(f => f.isNumber);
}
/** Premier paragraphe d'un texte (utilise pour la drop cap). */
firstParagraph(text: string): string {
if (!text) return '';
const paragraphs = text.split(/\n\s*\n/);
return paragraphs[0]?.trim() ?? '';
}
/** Reste du texte apres le 1er paragraphe. */
restAfterFirstParagraph(text: string): string {
if (!text) return '';
const paragraphs = text.split(/\n\s*\n/);
return paragraphs.slice(1).join('\n\n').trim();
}
}

View File

@@ -0,0 +1,14 @@
<div class="sip">
<div class="sip-frame" [style.aspectRatio]="aspectRatio">
<ng-container *ngIf="imageId; else uploadTpl">
<img [src]="contentUrl(imageId)" alt="" />
<button type="button" class="sip-remove" (click)="remove()" title="Retirer l'image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</ng-container>
<ng-template #uploadTpl>
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</ng-template>
</div>
<small *ngIf="hint" class="sip-hint">{{ hint }}</small>
</div>

View File

@@ -0,0 +1,48 @@
.sip {
display: flex;
flex-direction: column;
gap: 4px;
}
.sip-frame {
position: relative;
width: 100%;
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.sip-remove {
position: absolute;
top: 6px;
right: 6px;
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.65);
color: #fff;
border: none;
border-radius: 50%;
cursor: pointer;
transition: background 120ms;
&:hover {
background: rgba(220, 38, 38, 0.9);
}
}
.sip-hint {
font-size: 0.75rem;
font-style: italic;
color: var(--color-text-muted, #888);
}

View File

@@ -0,0 +1,60 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, X, Image as ImageIcon } from 'lucide-angular';
import { ImageService } from '../../services/image.service';
import { Image } from '../../services/image.model';
import { ImageUploaderComponent } from '../image-uploader/image-uploader.component';
/**
* Picker d'image unique : preview + upload + suppression.
*
* Usage :
* <app-single-image-picker [imageId]="portraitId" (imageIdChange)="portraitId = $event">
* </app-single-image-picker>
*
* Comportements :
* - Si imageId est defini : affiche la miniature avec un bouton X pour retirer
* - Sinon : affiche le bouton d'upload (compact mode)
*
* Le composant ne supprime pas l'image cote backend — il decouple juste le
* lien (passe imageId a null). L'image reste accessible via d'autres entites.
*/
@Component({
selector: 'app-single-image-picker',
standalone: true,
imports: [CommonModule, LucideAngularModule, ImageUploaderComponent],
templateUrl: './single-image-picker.component.html',
styleUrls: ['./single-image-picker.component.scss']
})
export class SingleImagePickerComponent {
readonly X = X;
readonly ImageIcon = ImageIcon;
@Input() imageId: string | null = null;
/** Texte d'aide affiche sous le picker (ex: "Format conseille : 400×400"). */
@Input() hint?: string;
/** Aspect ratio de la preview (CSS aspect-ratio property). */
@Input() aspectRatio = '1 / 1';
@Output() imageIdChange = new EventEmitter<string | null>();
constructor(private imageService: ImageService) {}
contentUrl(id: string): string {
return this.imageService.contentUrl(id);
}
onUploaded(img: Image): void {
if (img?.id) {
this.imageId = img.id;
this.imageIdChange.emit(this.imageId);
}
}
remove(): void {
this.imageId = null;
this.imageIdChange.emit(null);
}
}

View File

@@ -2,7 +2,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 { TemplateField, FieldType, ImageLayout } from '../../services/template-field.model';
import { TemplateField, FieldType, ImageLayout } from '../../services/template.model';
/**
* Editeur reutilisable d'une liste de TemplateField.