Refonte du système JDR + système de personnage joueurs / non joueurs :
Some checks failed
E2E Tests / e2e (push) Failing after 21s
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:
@@ -205,19 +205,23 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait une ligne de résumé depuis le markdown (1re ligne non-vide, non-titre).
|
||||
* Générique : utilisé pour les fiches PJ comme PNJ (mêmes besoins d'aperçu carte).
|
||||
* Extrait une ligne de resume pour la fiche PJ/PNJ — 1re valeur de template
|
||||
* non-vide (apres refonte 2026-04-30, remplace l'ancien parsing markdown).
|
||||
*/
|
||||
personaSnippet(p: { markdownContent?: string | null }): string {
|
||||
if (!p.markdownContent) return '(Fiche vide)';
|
||||
const firstMeaningful = p.markdownContent
|
||||
.split('\n')
|
||||
.map(l => l.trim())
|
||||
.find(l => l && !l.startsWith('#'));
|
||||
if (!firstMeaningful) return '(Fiche vide)';
|
||||
return firstMeaningful.length > 80
|
||||
? firstMeaningful.substring(0, 77) + '…'
|
||||
: firstMeaningful;
|
||||
personaSnippet(p: { values?: Record<string, string> }): string {
|
||||
const values = p.values ?? {};
|
||||
for (const v of Object.values(values)) {
|
||||
if (!v) continue;
|
||||
const firstMeaningful = v
|
||||
.split('\n')
|
||||
.map(l => l.trim())
|
||||
.find(l => l && !l.startsWith('#'));
|
||||
if (!firstMeaningful) continue;
|
||||
return firstMeaningful.length > 80
|
||||
? firstMeaningful.substring(0, 77) + '…'
|
||||
: firstMeaningful;
|
||||
}
|
||||
return '(Fiche vide)';
|
||||
}
|
||||
|
||||
/** Alias gardé pour compatibilité avec les anciens templates. */
|
||||
|
||||
@@ -35,18 +35,38 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field content-field">
|
||||
<label>Fiche (markdown)</label>
|
||||
<p class="hint">
|
||||
Tout en markdown libre : stats, classe, backstory, équipement, objectifs personnels…
|
||||
L'IA lira ces infos pour rester cohérente quand vous générez des scènes impliquant ce PJ.
|
||||
</p>
|
||||
<textarea
|
||||
[(ngModel)]="markdownContent"
|
||||
name="markdownContent"
|
||||
rows="22"
|
||||
placeholder="# Thorin Grand-Hache **Race :** Nain **Classe :** Guerrier niveau 4 **PV :** 35 / 35 ## Stats - Force : 16 - Dextérité : 12 ... ## Backstory Originaire des montagnes du Nord, Thorin a fui son clan après..."
|
||||
></textarea>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Portrait (ID image)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="portraitImageId"
|
||||
name="portraitImageId"
|
||||
placeholder="ID de l'image portrait"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Bandeau / Header (ID image)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="headerImageId"
|
||||
name="headerImageId"
|
||||
placeholder="ID de l'image bandeau"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">
|
||||
Les portraits et bandeaux acceptent un ID d'image (MVP). Picker visuel a venir.
|
||||
</p>
|
||||
|
||||
<div class="template-fields">
|
||||
<app-dynamic-fields-form
|
||||
[fields]="templateFields"
|
||||
[values]="values"
|
||||
[imageValues]="imageValues"
|
||||
(valuesChange)="values = $event"
|
||||
(imageValuesChange)="imageValues = $event">
|
||||
</app-dynamic-fields-form>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
|
||||
@@ -4,22 +4,27 @@ import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lucide-angular';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { Character } from '../../../services/character.model';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { GameSystemService } from '../../../services/game-system.service';
|
||||
import { TemplateField } from '../../../services/template-field.model';
|
||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
|
||||
|
||||
/**
|
||||
* Éditeur plein écran d'une fiche de personnage (PJ).
|
||||
* Double rôle création/édition :
|
||||
* - `/campaigns/:campaignId/characters/create` → POST
|
||||
* - `/campaigns/:campaignId/characters/:characterId/edit` → PUT
|
||||
* Editeur plein ecran d'une fiche de personnage (PJ).
|
||||
* Refonte 2026-04-30 : remplace le markdown libre par un formulaire dynamique
|
||||
* pilote par le characterTemplate du GameSystem associe a la campagne.
|
||||
*
|
||||
* MVP : name + markdown libre. Évolution prévue vers un template dérivé
|
||||
* du GameSystem de la campagne (stats structurées).
|
||||
* Comportements :
|
||||
* - Si la campagne n'a pas de GameSystem ou si son template est vide, affiche
|
||||
* uniquement les champs universels (nom, portrait, header).
|
||||
* - Le picker d'images dedie portrait/header est hors scope MVP — pour l'instant
|
||||
* saisie manuelle d'IDs d'images.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-character-edit',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent],
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent],
|
||||
templateUrl: './character-edit.component.html',
|
||||
styleUrls: ['./character-edit.component.scss']
|
||||
})
|
||||
@@ -30,12 +35,11 @@ export class CharacterEditComponent implements OnInit {
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Sparkles = Sparkles;
|
||||
|
||||
/** État drawer chat IA focalisé sur ce PJ. */
|
||||
chatOpen = false;
|
||||
readonly chatQuickSuggestions = [
|
||||
'Propose une backstory cohérente avec l\'univers',
|
||||
'Suggère 3 objectifs personnels pour ce personnage',
|
||||
'Aide-moi à équilibrer les stats de combat'
|
||||
'Propose une backstory coherente avec l\'univers',
|
||||
'Suggere 3 objectifs personnels pour ce personnage',
|
||||
'Aide-moi a equilibrer les stats de combat'
|
||||
];
|
||||
|
||||
toggleChat(): void { this.chatOpen = !this.chatOpen; }
|
||||
@@ -44,13 +48,19 @@ export class CharacterEditComponent implements OnInit {
|
||||
characterId: string | null = null;
|
||||
|
||||
name = '';
|
||||
markdownContent = '';
|
||||
portraitImageId = '';
|
||||
headerImageId = '';
|
||||
values: Record<string, string> = {};
|
||||
imageValues: Record<string, string[]> = {};
|
||||
templateFields: TemplateField[] = [];
|
||||
private order = 0;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private service: CharacterService
|
||||
private service: CharacterService,
|
||||
private campaignService: CampaignService,
|
||||
private gameSystemService: GameSystemService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -58,11 +68,18 @@ export class CharacterEditComponent implements OnInit {
|
||||
this.campaignId = params.get('campaignId');
|
||||
this.characterId = params.get('characterId');
|
||||
|
||||
if (this.campaignId) {
|
||||
this.loadTemplateForCampaign(this.campaignId);
|
||||
}
|
||||
|
||||
if (this.characterId) {
|
||||
this.service.getById(this.characterId).subscribe({
|
||||
next: (c) => {
|
||||
this.name = c.name;
|
||||
this.markdownContent = c.markdownContent ?? '';
|
||||
this.portraitImageId = c.portraitImageId ?? '';
|
||||
this.headerImageId = c.headerImageId ?? '';
|
||||
this.values = c.values ?? {};
|
||||
this.imageValues = c.imageValues ?? {};
|
||||
this.order = c.order ?? 0;
|
||||
},
|
||||
error: () => this.back()
|
||||
@@ -70,21 +87,35 @@ export class CharacterEditComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
private loadTemplateForCampaign(campaignId: string): void {
|
||||
this.campaignService.getCampaignById(campaignId).subscribe({
|
||||
next: (campaign) => {
|
||||
if (!campaign.gameSystemId) {
|
||||
this.templateFields = [];
|
||||
return;
|
||||
}
|
||||
this.gameSystemService.getById(campaign.gameSystemId).subscribe({
|
||||
next: (gs) => { this.templateFields = gs.characterTemplate ?? []; },
|
||||
error: () => { this.templateFields = []; }
|
||||
});
|
||||
},
|
||||
error: () => { this.templateFields = []; }
|
||||
});
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (!this.name.trim() || !this.campaignId) return;
|
||||
const payload = {
|
||||
name: this.name.trim(),
|
||||
portraitImageId: this.portraitImageId.trim() || null,
|
||||
headerImageId: this.headerImageId.trim() || null,
|
||||
values: this.values,
|
||||
imageValues: this.imageValues,
|
||||
campaignId: this.campaignId
|
||||
};
|
||||
const req = this.characterId
|
||||
? this.service.update(this.characterId, {
|
||||
id: this.characterId,
|
||||
name: this.name.trim(),
|
||||
markdownContent: this.markdownContent || null,
|
||||
campaignId: this.campaignId,
|
||||
order: this.order
|
||||
})
|
||||
: this.service.create({
|
||||
name: this.name.trim(),
|
||||
markdownContent: this.markdownContent || null,
|
||||
campaignId: this.campaignId
|
||||
});
|
||||
? this.service.update(this.characterId, { ...payload, id: this.characterId, order: this.order })
|
||||
: this.service.create(payload);
|
||||
req.subscribe({
|
||||
next: () => this.back(),
|
||||
error: () => console.error('Erreur sauvegarde Character')
|
||||
@@ -93,7 +124,7 @@ export class CharacterEditComponent implements OnInit {
|
||||
|
||||
deleteCharacter(): void {
|
||||
if (!this.characterId) return;
|
||||
if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irréversible.`)) return;
|
||||
if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irreversible.`)) return;
|
||||
this.service.delete(this.characterId).subscribe({
|
||||
next: () => this.back(),
|
||||
error: () => console.error('Erreur suppression Character')
|
||||
|
||||
@@ -35,18 +35,28 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field content-field">
|
||||
<label>Fiche (markdown)</label>
|
||||
<p class="hint">
|
||||
Tout en markdown libre : apparence, motivations, faction, secrets, stats, notes MJ…
|
||||
À terme, l'IA pourra exploiter ces infos pour incarner le PNJ avec cohérence dans les scènes.
|
||||
</p>
|
||||
<textarea
|
||||
[(ngModel)]="markdownContent"
|
||||
name="markdownContent"
|
||||
rows="22"
|
||||
placeholder="# Borin le forgeron **Race :** Nain **Faction :** Clan Feuillefer **Statut :** Vivant ## Apparence Barbe rousse tressée, tablier de cuir brûlé... ## Motivations Venger son clan décimé par les orcs il y a 10 hivers. ## Notes MJ (secret) Connaît l'emplacement du marteau de Durin..."
|
||||
></textarea>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Portrait (ID image)</label>
|
||||
<input type="text" [(ngModel)]="portraitImageId" name="portraitImageId" placeholder="ID de l'image portrait" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Bandeau / Header (ID image)</label>
|
||||
<input type="text" [(ngModel)]="headerImageId" name="headerImageId" placeholder="ID de l'image bandeau" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">
|
||||
Les portraits et bandeaux acceptent un ID d'image (MVP). Picker visuel a venir.
|
||||
</p>
|
||||
|
||||
<div class="template-fields">
|
||||
<app-dynamic-fields-form
|
||||
[fields]="templateFields"
|
||||
[values]="values"
|
||||
[imageValues]="imageValues"
|
||||
(valuesChange)="values = $event"
|
||||
(imageValuesChange)="imageValues = $event">
|
||||
</app-dynamic-fields-form>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
|
||||
@@ -4,21 +4,22 @@ import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, Save, ArrowLeft, Drama, Trash2, Sparkles } from 'lucide-angular';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { GameSystemService } from '../../../services/game-system.service';
|
||||
import { TemplateField } from '../../../services/template-field.model';
|
||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
|
||||
|
||||
/**
|
||||
* Éditeur plein écran d'une fiche de PNJ.
|
||||
* Double rôle création/édition :
|
||||
* - `/campaigns/:campaignId/npcs/create` → POST
|
||||
* - `/campaigns/:campaignId/npcs/:npcId/edit` → PUT
|
||||
*
|
||||
* MVP : name + markdown libre. L'Assistant IA est branché en mode édition
|
||||
* (focus entityType="npc") pour proposer apparence, motivations, secrets...
|
||||
* Editeur plein ecran d'une fiche de PNJ.
|
||||
* Refonte 2026-04-30 : meme principe que CharacterEditComponent — markdown
|
||||
* libre remplace par un formulaire dynamique pilote par le npcTemplate du
|
||||
* GameSystem associe a la campagne.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-npc-edit',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent],
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent],
|
||||
templateUrl: './npc-edit.component.html',
|
||||
styleUrls: ['./npc-edit.component.scss']
|
||||
})
|
||||
@@ -29,12 +30,11 @@ export class NpcEditComponent implements OnInit {
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Sparkles = Sparkles;
|
||||
|
||||
/** État drawer chat IA focalisé sur ce PNJ. */
|
||||
chatOpen = false;
|
||||
readonly chatQuickSuggestions = [
|
||||
'Propose une apparence et une posture marquantes',
|
||||
'Suggère 2 motivations et un secret pour ce PNJ',
|
||||
'Imagine 3 répliques signatures qui le caractérisent'
|
||||
'Suggere 2 motivations et un secret pour ce PNJ',
|
||||
'Imagine 3 repliques signatures qui le caracterisent'
|
||||
];
|
||||
|
||||
toggleChat(): void { this.chatOpen = !this.chatOpen; }
|
||||
@@ -43,13 +43,19 @@ export class NpcEditComponent implements OnInit {
|
||||
npcId: string | null = null;
|
||||
|
||||
name = '';
|
||||
markdownContent = '';
|
||||
portraitImageId = '';
|
||||
headerImageId = '';
|
||||
values: Record<string, string> = {};
|
||||
imageValues: Record<string, string[]> = {};
|
||||
templateFields: TemplateField[] = [];
|
||||
private order = 0;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private service: NpcService
|
||||
private service: NpcService,
|
||||
private campaignService: CampaignService,
|
||||
private gameSystemService: GameSystemService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -57,11 +63,18 @@ export class NpcEditComponent implements OnInit {
|
||||
this.campaignId = params.get('campaignId');
|
||||
this.npcId = params.get('npcId');
|
||||
|
||||
if (this.campaignId) {
|
||||
this.loadTemplateForCampaign(this.campaignId);
|
||||
}
|
||||
|
||||
if (this.npcId) {
|
||||
this.service.getById(this.npcId).subscribe({
|
||||
next: (n) => {
|
||||
this.name = n.name;
|
||||
this.markdownContent = n.markdownContent ?? '';
|
||||
this.portraitImageId = n.portraitImageId ?? '';
|
||||
this.headerImageId = n.headerImageId ?? '';
|
||||
this.values = n.values ?? {};
|
||||
this.imageValues = n.imageValues ?? {};
|
||||
this.order = n.order ?? 0;
|
||||
},
|
||||
error: () => this.back()
|
||||
@@ -69,21 +82,35 @@ export class NpcEditComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
private loadTemplateForCampaign(campaignId: string): void {
|
||||
this.campaignService.getCampaignById(campaignId).subscribe({
|
||||
next: (campaign) => {
|
||||
if (!campaign.gameSystemId) {
|
||||
this.templateFields = [];
|
||||
return;
|
||||
}
|
||||
this.gameSystemService.getById(campaign.gameSystemId).subscribe({
|
||||
next: (gs) => { this.templateFields = gs.npcTemplate ?? []; },
|
||||
error: () => { this.templateFields = []; }
|
||||
});
|
||||
},
|
||||
error: () => { this.templateFields = []; }
|
||||
});
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (!this.name.trim() || !this.campaignId) return;
|
||||
const payload = {
|
||||
name: this.name.trim(),
|
||||
portraitImageId: this.portraitImageId.trim() || null,
|
||||
headerImageId: this.headerImageId.trim() || null,
|
||||
values: this.values,
|
||||
imageValues: this.imageValues,
|
||||
campaignId: this.campaignId
|
||||
};
|
||||
const req = this.npcId
|
||||
? this.service.update(this.npcId, {
|
||||
id: this.npcId,
|
||||
name: this.name.trim(),
|
||||
markdownContent: this.markdownContent || null,
|
||||
campaignId: this.campaignId,
|
||||
order: this.order
|
||||
})
|
||||
: this.service.create({
|
||||
name: this.name.trim(),
|
||||
markdownContent: this.markdownContent || null,
|
||||
campaignId: this.campaignId
|
||||
});
|
||||
? this.service.update(this.npcId, { ...payload, id: this.npcId, order: this.order })
|
||||
: this.service.create(payload);
|
||||
req.subscribe({
|
||||
next: () => this.back(),
|
||||
error: () => console.error('Erreur sauvegarde Npc')
|
||||
@@ -92,7 +119,7 @@ export class NpcEditComponent implements OnInit {
|
||||
|
||||
deleteNpc(): void {
|
||||
if (!this.npcId) return;
|
||||
if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irréversible.`)) return;
|
||||
if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irreversible.`)) return;
|
||||
this.service.delete(this.npcId).subscribe({
|
||||
next: () => this.back(),
|
||||
error: () => console.error('Erreur suppression Npc')
|
||||
|
||||
@@ -90,6 +90,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Templates de fiches PJ/PNJ -->
|
||||
<div class="templates-area">
|
||||
<h2 class="sections-title">Fiches de personnages</h2>
|
||||
<p class="sections-hint">
|
||||
Definissez la structure des fiches PJ et PNJ pour ce systeme. Les champs
|
||||
universels (nom, portrait, header) sont automatiques — ne rajoutez ici
|
||||
que les champs specifiques au systeme (Histoire, PV, Stats…).
|
||||
</p>
|
||||
|
||||
<app-template-fields-editor
|
||||
label="Champs de la fiche PJ"
|
||||
hint="Affiches lors de la creation/edition d'un personnage joueur."
|
||||
[fields]="characterTemplate"
|
||||
[suggestions]="characterFieldSuggestions"
|
||||
(fieldsChange)="characterTemplate = $event">
|
||||
</app-template-fields-editor>
|
||||
|
||||
<app-template-fields-editor
|
||||
label="Champs de la fiche PNJ"
|
||||
hint="Affiches lors de la creation/edition d'un personnage non-joueur."
|
||||
[fields]="npcTemplate"
|
||||
[suggestions]="npcFieldSuggestions"
|
||||
(fieldsChange)="npcTemplate = $event">
|
||||
</app-template-fields-editor>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
|
||||
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.templates-area {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.gse-header {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, Save, ArrowLeft, Dices, Plus, Trash2, ChevronDown, ChevronRight } from 'lucide-angular';
|
||||
import { GameSystemService } from '../../services/game-system.service';
|
||||
import { TemplateField } from '../../services/template-field.model';
|
||||
import { TemplateFieldsEditorComponent } from '../../shared/template-fields-editor/template-fields-editor.component';
|
||||
|
||||
/**
|
||||
* Éditeur plein écran d'un GameSystem. Rôle double création/édition :
|
||||
@@ -31,10 +33,16 @@ const SUGGESTED_SECTIONS = [
|
||||
'Combat', 'Classes', 'Stats', 'Magie', 'Monstres', 'Progression'
|
||||
];
|
||||
|
||||
/** Suggestions de champs pour la fiche PJ — generiques (extension par template). */
|
||||
const CHARACTER_FIELD_SUGGESTIONS = ['Histoire', 'Personnalite', 'Apparence', 'Notes'];
|
||||
|
||||
/** Suggestions de champs pour la fiche PNJ — focus sur les besoins MJ. */
|
||||
const NPC_FIELD_SUGGESTIONS = ['Motivation', 'Apparence', 'Faction', 'Notes MJ'];
|
||||
|
||||
@Component({
|
||||
selector: 'app-game-system-edit',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule],
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule, TemplateFieldsEditorComponent],
|
||||
templateUrl: './game-system-edit.component.html',
|
||||
styleUrls: ['./game-system-edit.component.scss']
|
||||
})
|
||||
@@ -53,8 +61,12 @@ export class GameSystemEditComponent implements OnInit {
|
||||
description = '';
|
||||
author = '';
|
||||
sections: RuleSection[] = [];
|
||||
characterTemplate: TemplateField[] = [];
|
||||
npcTemplate: TemplateField[] = [];
|
||||
|
||||
readonly suggestedSections = SUGGESTED_SECTIONS;
|
||||
readonly characterFieldSuggestions = CHARACTER_FIELD_SUGGESTIONS;
|
||||
readonly npcFieldSuggestions = NPC_FIELD_SUGGESTIONS;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
@@ -71,6 +83,8 @@ export class GameSystemEditComponent implements OnInit {
|
||||
this.description = gs.description ?? '';
|
||||
this.author = gs.author ?? '';
|
||||
this.sections = this.parseMarkdown(gs.rulesMarkdown ?? '');
|
||||
this.characterTemplate = gs.characterTemplate ? [...gs.characterTemplate] : [];
|
||||
this.npcTemplate = gs.npcTemplate ? [...gs.npcTemplate] : [];
|
||||
},
|
||||
error: () => this.back()
|
||||
});
|
||||
@@ -104,11 +118,17 @@ export class GameSystemEditComponent implements OnInit {
|
||||
|
||||
submit(): void {
|
||||
if (!this.name.trim()) return;
|
||||
if (this.hasInvalidTemplateFields()) {
|
||||
console.warn('Champs templates invalides (noms vides ou doublons) — sauvegarde bloquee.');
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
name: this.name.trim(),
|
||||
description: this.description.trim() || null,
|
||||
author: this.author.trim() || null,
|
||||
rulesMarkdown: this.serializeMarkdown(),
|
||||
characterTemplate: this.characterTemplate,
|
||||
npcTemplate: this.npcTemplate,
|
||||
isPublic: false
|
||||
};
|
||||
const req = this.id
|
||||
@@ -124,6 +144,22 @@ export class GameSystemEditComponent implements OnInit {
|
||||
this.router.navigate(['/game-systems']);
|
||||
}
|
||||
|
||||
/** Validation cote front : nom vide ou doublons (case-insensitive). */
|
||||
private hasInvalidTemplateFields(): boolean {
|
||||
return this.hasInvalidList(this.characterTemplate) || this.hasInvalidList(this.npcTemplate);
|
||||
}
|
||||
|
||||
private hasInvalidList(fields: TemplateField[]): boolean {
|
||||
const seen = new Set<string>();
|
||||
for (const f of fields) {
|
||||
const name = f.name?.trim().toLowerCase();
|
||||
if (!name) return true;
|
||||
if (seen.has(name)) return true;
|
||||
seen.add(name);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Parse / Serialize markdown ------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
/**
|
||||
* Fiche de personnage joueur (PJ) d'une campagne.
|
||||
* MVP : markdownContent libre. Évolution prévue vers des fiches templatées
|
||||
* par GameSystem (stats structurées selon le JDR joué).
|
||||
* Refonte 2026-04-30 : abandon du markdownContent au profit d'un systeme
|
||||
* template-based pilote par le GameSystem de la campagne.
|
||||
* - portraitImageId / headerImageId : champs universels hard-codes
|
||||
* - values : Map<champ template TEXT/NUMBER, valeur>
|
||||
* - imageValues : Map<champ template IMAGE, liste d'IDs d'images>
|
||||
*/
|
||||
export interface Character {
|
||||
id?: string;
|
||||
name: string;
|
||||
markdownContent?: string | null;
|
||||
portraitImageId?: string | null;
|
||||
headerImageId?: string | null;
|
||||
values?: Record<string, string>;
|
||||
imageValues?: Record<string, string[]>;
|
||||
campaignId: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface CharacterCreate {
|
||||
name: string;
|
||||
markdownContent?: string | null;
|
||||
portraitImageId?: string | null;
|
||||
headerImageId?: string | null;
|
||||
values?: Record<string, string>;
|
||||
imageValues?: Record<string, string[]>;
|
||||
campaignId: string;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
import { TemplateField } from './template-field.model';
|
||||
|
||||
/**
|
||||
* Interface TypeScript pour GameSystemDTO (jumeau du DTO Java).
|
||||
*
|
||||
* rulesMarkdown : markdown monolithique, sections découpées par titres H2
|
||||
* (## Combat, ## Classes, etc.). Le découpage et la sélection des sections
|
||||
* à injecter dans le prompt IA sont faits côté backend Java.
|
||||
* rulesMarkdown : markdown monolithique, sections decoupees par titres H2.
|
||||
* characterTemplate / npcTemplate : champs templates pilotant le rendu des
|
||||
* fiches PJ / PNJ d'une campagne adossee a ce systeme (cf. refonte 2026-04-30).
|
||||
*/
|
||||
export interface GameSystem {
|
||||
id?: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
rulesMarkdown?: string | null;
|
||||
characterTemplate?: TemplateField[];
|
||||
npcTemplate?: TemplateField[];
|
||||
author?: string | null;
|
||||
isPublic?: boolean;
|
||||
}
|
||||
|
||||
/** Payload de création/mise à jour (sans id). */
|
||||
/** Payload de creation/mise a jour (sans id). */
|
||||
export interface GameSystemCreate {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
rulesMarkdown?: string | null;
|
||||
characterTemplate?: TemplateField[];
|
||||
npcTemplate?: TemplateField[];
|
||||
author?: string | null;
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
/**
|
||||
* Fiche de personnage non-joueur (PNJ) d'une campagne.
|
||||
* MVP : markdownContent libre (description, motivation, stats, notes MJ).
|
||||
* Évolution prévue : templating partagé PJ/PNJ piloté par GameSystem.
|
||||
* Refonte 2026-04-30 : meme structure que Character (template-based).
|
||||
*/
|
||||
export interface Npc {
|
||||
id?: string;
|
||||
name: string;
|
||||
markdownContent?: string | null;
|
||||
portraitImageId?: string | null;
|
||||
headerImageId?: string | null;
|
||||
values?: Record<string, string>;
|
||||
imageValues?: Record<string, string[]>;
|
||||
campaignId: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface NpcCreate {
|
||||
name: string;
|
||||
markdownContent?: string | null;
|
||||
portraitImageId?: string | null;
|
||||
headerImageId?: string | null;
|
||||
values?: Record<string, string>;
|
||||
imageValues?: Record<string, string[]>;
|
||||
campaignId: string;
|
||||
}
|
||||
|
||||
15
web/src/app/services/template-field.model.ts
Normal file
15
web/src/app/services/template-field.model.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Interface TypeScript pour TemplateFieldDTO (kernel partage cote backend).
|
||||
* Decrit un champ d'un template (PJ, PNJ, Lore Page).
|
||||
*
|
||||
* type : "TEXT" | "IMAGE" | "NUMBER"
|
||||
* layout : "GALLERY" | "HERO" | "MASONRY" | "CAROUSEL", null sauf si type=IMAGE
|
||||
*/
|
||||
export type FieldType = 'TEXT' | 'IMAGE' | 'NUMBER';
|
||||
export type ImageLayout = 'GALLERY' | 'HERO' | 'MASONRY' | 'CAROUSEL';
|
||||
|
||||
export interface TemplateField {
|
||||
name: string;
|
||||
type: FieldType;
|
||||
layout?: ImageLayout | null;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user