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

@@ -0,0 +1,93 @@
package com.loremind.infrastructure.persistence;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* Backfill one-shot des fiches Character / Npc post-refonte 2026-04-30.
* <p>
* Avant la refonte, les fiches stockaient leur contenu dans la colonne
* {@code markdown_content}. Apres la refonte, le contenu est dans
* {@code field_values} (JSON Map<String,String>). La colonne
* {@code markdown_content} subsiste car Hibernate ddl-auto=update ne drop pas.
* <p>
* Ce backfill copie {@code markdown_content} dans {@code field_values["Notes"]}
* pour toutes les fiches qui ont un markdown non vide ET un field_values vide.
* Idempotent : si field_values contient deja des donnees, on ne touche pas.
* <p>
* La colonne {@code markdown_content} n'est PAS supprimee apres backfill —
* permet un rollback applicatif au cas ou. Suppression definitive a faire dans
* une release ulterieure quand la confiance est etablie.
*/
@Component
public class CharacterNpcMarkdownBackfill {
private static final Logger log = LoggerFactory.getLogger(CharacterNpcMarkdownBackfill.class);
private final JdbcTemplate jdbc;
private final ObjectMapper mapper = new ObjectMapper();
public CharacterNpcMarkdownBackfill(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@EventListener(ApplicationReadyEvent.class)
public void backfillIfNeeded() {
if (!hasMarkdownContentColumn("characters")) {
log.debug("Backfill skip : colonne markdown_content absente (deja migre ou install propre).");
return;
}
int chars = backfillTable("characters");
int npcs = backfillTable("npcs");
if (chars + npcs > 0) {
log.info("Backfill markdown -> field_values : {} character(s), {} npc(s) migre(s).", chars, npcs);
}
}
private boolean hasMarkdownContentColumn(String table) {
try {
Integer count = jdbc.queryForObject(
"SELECT COUNT(*) FROM information_schema.columns "
+ "WHERE table_name = ? AND column_name = 'markdown_content'",
Integer.class, table);
return count != null && count > 0;
} catch (Exception e) {
log.warn("Backfill : impossible de verifier la colonne markdown_content sur {}: {}",
table, e.getMessage());
return false;
}
}
private int backfillTable(String table) {
// Selection : fiches avec markdown non vide ET field_values vide ou absent.
// field_values peut etre NULL (legacy avant refonte) ou "{}" (refonte appliquee mais sans data).
String selectSql = "SELECT id, markdown_content FROM " + table
+ " WHERE markdown_content IS NOT NULL "
+ " AND markdown_content <> '' "
+ " AND (field_values IS NULL OR field_values = '' OR field_values = '{}')";
var rows = jdbc.queryForList(selectSql);
int migrated = 0;
for (var row : rows) {
Long id = ((Number) row.get("id")).longValue();
String markdown = (String) row.get("markdown_content");
String json;
try {
json = mapper.writeValueAsString(Map.of("Notes", markdown));
} catch (Exception e) {
log.error("Backfill {} id={} : echec serialisation JSON, ignore. {}", table, id, e.getMessage());
continue;
}
jdbc.update("UPDATE " + table + " SET field_values = ? WHERE id = ?", json, id);
migrated++;
}
return migrated;
}
}

View File

@@ -2,6 +2,8 @@ package com.loremind.infrastructure.persistence;
import com.loremind.domain.gamesystemcontext.GameSystem; import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository; import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import com.loremind.domain.shared.template.ImageLayout;
import com.loremind.domain.shared.template.TemplateField;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.boot.context.event.ApplicationReadyEvent;
@@ -23,6 +25,10 @@ import java.util.List;
* <p> * <p>
* Idempotence : ne seed qu'une fois. Si l'utilisateur supprime un ruleset seedé, * Idempotence : ne seed qu'une fois. Si l'utilisateur supprime un ruleset seedé,
* il ne revient pas au redémarrage — c'est voulu (respect du choix utilisateur). * il ne revient pas au redémarrage — c'est voulu (respect du choix utilisateur).
* <p>
* Backfill 2026-04-30 : pour les GameSystems existants (avant la refonte
* template-based), on remplit aussi les templates PJ/PNJ par defaut s'ils
* sont vides — sinon les fiches restent inutilisables.
*/ */
@Component @Component
public class GameSystemSeeder { public class GameSystemSeeder {
@@ -37,15 +43,37 @@ public class GameSystemSeeder {
@EventListener(ApplicationReadyEvent.class) @EventListener(ApplicationReadyEvent.class)
public void seedIfEmpty() { public void seedIfEmpty() {
if (!gameSystemRepository.findAll().isEmpty()) { List<GameSystem> existing = gameSystemRepository.findAll();
log.debug("GameSystem seed skipped — table non vide."); if (existing.isEmpty()) {
log.info("Seed initial des GameSystems (table vide)...");
for (GameSystem gs : defaultSystems()) {
gameSystemRepository.save(gs);
}
log.info("GameSystems seedés : {}", defaultSystems().size());
return; return;
} }
log.info("Seed initial des GameSystems (table vide)..."); log.debug("GameSystem seed skipped — table non vide. Backfill templates si necessaire...");
for (GameSystem gs : defaultSystems()) { backfillEmptyTemplates(existing);
gameSystemRepository.save(gs); }
/**
* Backfill idempotent : pour chaque GameSystem existant ou les deux templates
* sont vides (PJ ET PNJ), injecte le template generique. Si l'utilisateur a
* deja personnalise au moins un des deux, on ne touche a rien.
*/
private void backfillEmptyTemplates(List<GameSystem> systems) {
int patched = 0;
for (GameSystem gs : systems) {
boolean charEmpty = gs.getCharacterTemplate() == null || gs.getCharacterTemplate().isEmpty();
boolean npcEmpty = gs.getNpcTemplate() == null || gs.getNpcTemplate().isEmpty();
if (charEmpty && npcEmpty) {
gs.replaceCharacterTemplate(genericCharacterTemplate());
gs.replaceNpcTemplate(genericNpcTemplate());
gameSystemRepository.save(gs);
patched++;
}
} }
log.info("GameSystems seedés : {}", defaultSystems().size()); if (patched > 0) log.info("Backfill templates GameSystem : {} systeme(s) patche(s).", patched);
} }
private List<GameSystem> defaultSystems() { private List<GameSystem> defaultSystems() {
@@ -56,6 +84,8 @@ public class GameSystemSeeder {
.author("LoreMind seed") .author("LoreMind seed")
.isPublic(false) .isPublic(false)
.rulesMarkdown(NIMBLE_RULES) .rulesMarkdown(NIMBLE_RULES)
.characterTemplate(nimbleCharacterTemplate())
.npcTemplate(genericNpcTemplate())
.build(), .build(),
GameSystem.builder() GameSystem.builder()
.name("D&D 5e SRD (extrait)") .name("D&D 5e SRD (extrait)")
@@ -63,6 +93,8 @@ public class GameSystemSeeder {
.author("LoreMind seed") .author("LoreMind seed")
.isPublic(false) .isPublic(false)
.rulesMarkdown(DND_SRD_RULES) .rulesMarkdown(DND_SRD_RULES)
.characterTemplate(dndCharacterTemplate())
.npcTemplate(genericNpcTemplate())
.build(), .build(),
GameSystem.builder() GameSystem.builder()
.name("Homebrew Exemple") .name("Homebrew Exemple")
@@ -70,10 +102,70 @@ public class GameSystemSeeder {
.author("LoreMind seed") .author("LoreMind seed")
.isPublic(false) .isPublic(false)
.rulesMarkdown(HOMEBREW_EXAMPLE) .rulesMarkdown(HOMEBREW_EXAMPLE)
.characterTemplate(genericCharacterTemplate())
.npcTemplate(genericNpcTemplate())
.build() .build()
); );
} }
// --- Templates par defaut ---------------------------------------------
/** Template generique PJ — utilise pour Homebrew, backfill, et fallback. */
private static List<TemplateField> genericCharacterTemplate() {
return List.of(
TemplateField.text("Histoire"),
TemplateField.text("Personnalite"),
TemplateField.text("Apparence"),
TemplateField.image("Galerie", ImageLayout.GALLERY),
TemplateField.text("Notes")
);
}
/** Template generique PNJ — focus besoins MJ. */
private static List<TemplateField> genericNpcTemplate() {
return List.of(
TemplateField.text("Apparence"),
TemplateField.text("Motivation"),
TemplateField.text("Faction"),
TemplateField.text("Notes MJ")
);
}
private static List<TemplateField> nimbleCharacterTemplate() {
return List.of(
TemplateField.text("Classe"),
TemplateField.number("Blessures graves max"),
TemplateField.text("Capacites de classe"),
TemplateField.text("Equipement"),
TemplateField.text("Histoire"),
TemplateField.text("Objectifs personnels"),
TemplateField.image("Galerie", ImageLayout.GALLERY)
);
}
private static List<TemplateField> dndCharacterTemplate() {
return List.of(
TemplateField.text("Classe"),
TemplateField.text("Race"),
TemplateField.text("Historique"),
TemplateField.text("Alignement"),
TemplateField.number("Niveau"),
TemplateField.number("PV max"),
TemplateField.number("CA"),
TemplateField.number("FOR"),
TemplateField.number("DEX"),
TemplateField.number("CON"),
TemplateField.number("INT"),
TemplateField.number("SAG"),
TemplateField.number("CHA"),
TemplateField.text("Competences"),
TemplateField.text("Equipement"),
TemplateField.text("Sorts"),
TemplateField.text("Histoire"),
TemplateField.image("Galerie", ImageLayout.GALLERY)
);
}
private static final String NIMBLE_RULES = """ private static final String NIMBLE_RULES = """
Système Nimble — résolution rapide, narration fluide, peu de tableaux. Agnostique (aucun univers imposé). Système Nimble — résolution rapide, narration fluide, peu de tableaux. Agnostique (aucun univers imposé).

View File

@@ -18,8 +18,10 @@ export const routes: Routes = [
{ path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) }, { path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) },
{ path: 'campaigns/:campaignId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) }, { path: 'campaigns/:campaignId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
{ path: 'campaigns/:campaignId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) }, { path: 'campaigns/:campaignId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
{ path: 'campaigns/:campaignId/characters/:characterId', loadComponent: () => import('./campaigns/character/character-view/character-view.component').then(m => m.CharacterViewComponent) },
{ path: 'campaigns/:campaignId/npcs/create', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) }, { path: 'campaigns/:campaignId/npcs/create', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
{ path: 'campaigns/:campaignId/npcs/:npcId/edit', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) }, { path: 'campaigns/:campaignId/npcs/:npcId/edit', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
{ path: 'campaigns/:campaignId/npcs/:npcId', loadComponent: () => import('./campaigns/npc/npc-view/npc-view.component').then(m => m.NpcViewComponent) },
{ path: 'campaigns/:campaignId/arcs/create', loadComponent: () => import('./campaigns/arc/arc-create/arc-create.component').then(m => m.ArcCreateComponent) }, { path: 'campaigns/:campaignId/arcs/create', loadComponent: () => import('./campaigns/arc/arc-create/arc-create.component').then(m => m.ArcCreateComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId', loadComponent: () => import('./campaigns/arc/arc-view/arc-view.component').then(m => m.ArcViewComponent) }, { path: 'campaigns/:campaignId/arcs/:arcId', loadComponent: () => import('./campaigns/arc/arc-view/arc-view.component').then(m => m.ArcViewComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/edit', loadComponent: () => import('./campaigns/arc/arc-edit/arc-edit.component').then(m => m.ArcEditComponent) }, { path: 'campaigns/:campaignId/arcs/:arcId/edit', loadComponent: () => import('./campaigns/arc/arc-edit/arc-edit.component').then(m => m.ArcEditComponent) },

View File

@@ -90,7 +90,7 @@
</div> </div>
<div class="characters-grid" *ngIf="characters.length > 0"> <div class="characters-grid" *ngIf="characters.length > 0">
<div class="character-card" *ngFor="let character of characters" (click)="editCharacter(character)"> <div class="character-card" *ngFor="let character of characters" (click)="viewCharacter(character)">
<lucide-icon [img]="User" [size]="20" class="character-icon"></lucide-icon> <lucide-icon [img]="User" [size]="20" class="character-icon"></lucide-icon>
<div class="character-info"> <div class="character-info">
<span class="character-name">{{ character.name }}</span> <span class="character-name">{{ character.name }}</span>
@@ -123,7 +123,7 @@
</div> </div>
<div class="characters-grid" *ngIf="npcs.length > 0"> <div class="characters-grid" *ngIf="npcs.length > 0">
<div class="character-card" *ngFor="let npc of npcs" (click)="editNpc(npc)"> <div class="character-card" *ngFor="let npc of npcs" (click)="viewNpc(npc)">
<lucide-icon [img]="Drama" [size]="20" class="character-icon character-icon--npc"></lucide-icon> <lucide-icon [img]="Drama" [size]="20" class="character-icon character-icon--npc"></lucide-icon>
<div class="character-info"> <div class="character-info">
<span class="character-name">{{ npc.name }}</span> <span class="character-name">{{ npc.name }}</span>

View File

@@ -194,6 +194,17 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
this.router.navigate(['/campaigns', this.campaign.id, 'characters', character.id, 'edit']); this.router.navigate(['/campaigns', this.campaign.id, 'characters', character.id, 'edit']);
} }
/** Ouvre la vue lecture seule (style WorldAnvil) — clic sur la carte. */
viewCharacter(character: Character): void {
if (!this.campaign || !character.id) return;
this.router.navigate(['/campaigns', this.campaign.id, 'characters', character.id]);
}
viewNpc(npc: Npc): void {
if (!this.campaign || !npc.id) return;
this.router.navigate(['/campaigns', this.campaign.id, 'npcs', npc.id]);
}
createArc(): void { createArc(): void {
if (!this.campaign) return; if (!this.campaign) return;
this.router.navigate(['/campaigns', this.campaign.id, 'arcs', 'create']); this.router.navigate(['/campaigns', this.campaign.id, 'arcs', 'create']);

View File

@@ -35,29 +35,26 @@
/> />
</div> </div>
<div class="field-row"> <div class="field-row image-row">
<div class="field"> <div class="field portrait-field">
<label>Portrait (ID image)</label> <label>Portrait</label>
<input <app-single-image-picker
type="text" [imageId]="portraitImageId"
[(ngModel)]="portraitImageId" aspectRatio="1 / 1"
name="portraitImageId" hint="Carre conseille (400×400)."
placeholder="ID de l'image portrait" (imageIdChange)="portraitImageId = $event">
/> </app-single-image-picker>
</div> </div>
<div class="field"> <div class="field header-field">
<label>Bandeau / Header (ID image)</label> <label>Bandeau / Header</label>
<input <app-single-image-picker
type="text" [imageId]="headerImageId"
[(ngModel)]="headerImageId" aspectRatio="3 / 1"
name="headerImageId" hint="Format paysage conseille (1200×400)."
placeholder="ID de l'image bandeau" (imageIdChange)="headerImageId = $event">
/> </app-single-image-picker>
</div> </div>
</div> </div>
<p class="hint">
Les portraits et bandeaux acceptent un ID d'image (MVP). Picker visuel a venir.
</p>
<div class="template-fields"> <div class="template-fields">
<app-dynamic-fields-form <app-dynamic-fields-form

View File

@@ -6,9 +6,10 @@ import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lu
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { GameSystemService } from '../../../services/game-system.service'; import { GameSystemService } from '../../../services/game-system.service';
import { TemplateField } from '../../../services/template-field.model'; import { TemplateField } from '../../../services/template.model';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component'; import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component'; import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
import { SingleImagePickerComponent } from '../../../shared/single-image-picker/single-image-picker.component';
/** /**
* Editeur plein ecran d'une fiche de personnage (PJ). * Editeur plein ecran d'une fiche de personnage (PJ).
@@ -24,7 +25,7 @@ import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/
@Component({ @Component({
selector: 'app-character-edit', selector: 'app-character-edit',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent], imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent],
templateUrl: './character-edit.component.html', templateUrl: './character-edit.component.html',
styleUrls: ['./character-edit.component.scss'] styleUrls: ['./character-edit.component.scss']
}) })
@@ -48,8 +49,8 @@ export class CharacterEditComponent implements OnInit {
characterId: string | null = null; characterId: string | null = null;
name = ''; name = '';
portraitImageId = ''; portraitImageId: string | null = null;
headerImageId = ''; headerImageId: string | null = null;
values: Record<string, string> = {}; values: Record<string, string> = {};
imageValues: Record<string, string[]> = {}; imageValues: Record<string, string[]> = {};
templateFields: TemplateField[] = []; templateFields: TemplateField[] = [];
@@ -76,8 +77,8 @@ export class CharacterEditComponent implements OnInit {
this.service.getById(this.characterId).subscribe({ this.service.getById(this.characterId).subscribe({
next: (c) => { next: (c) => {
this.name = c.name; this.name = c.name;
this.portraitImageId = c.portraitImageId ?? ''; this.portraitImageId = c.portraitImageId ?? null;
this.headerImageId = c.headerImageId ?? ''; this.headerImageId = c.headerImageId ?? null;
this.values = c.values ?? {}; this.values = c.values ?? {};
this.imageValues = c.imageValues ?? {}; this.imageValues = c.imageValues ?? {};
this.order = c.order ?? 0; this.order = c.order ?? 0;
@@ -107,8 +108,8 @@ export class CharacterEditComponent implements OnInit {
if (!this.name.trim() || !this.campaignId) return; if (!this.name.trim() || !this.campaignId) return;
const payload = { const payload = {
name: this.name.trim(), name: this.name.trim(),
portraitImageId: this.portraitImageId.trim() || null, portraitImageId: this.portraitImageId,
headerImageId: this.headerImageId.trim() || null, headerImageId: this.headerImageId,
values: this.values, values: this.values,
imageValues: this.imageValues, imageValues: this.imageValues,
campaignId: this.campaignId campaignId: this.campaignId

View File

@@ -0,0 +1,28 @@
<div class="cv-page">
<div class="cv-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour
</button>
<span class="spacer"></span>
<button class="btn-ai" (click)="toggleChat()" [class.active]="chatOpen" *ngIf="characterId">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
<button class="btn-edit" (click)="edit()">
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon>
Editer
</button>
</div>
<app-persona-view [persona]="character" [templateFields]="templateFields"></app-persona-view>
</div>
<app-ai-chat-drawer
*ngIf="characterId && campaignId"
[campaignId]="campaignId"
entityType="character"
[entityId]="characterId"
[isOpen]="chatOpen"
(close)="chatOpen = false">
</app-ai-chat-drawer>

View File

@@ -0,0 +1,53 @@
.cv-page {
padding: 16px 0 48px;
min-height: 100vh;
}
.cv-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 32px 16px;
max-width: 1100px;
margin: 0 auto;
.spacer { flex: 1; }
}
.btn-back,
.btn-edit,
.btn-ai {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 4px;
color: #d1d5db;
font-size: 0.85rem;
cursor: pointer;
transition: all 120ms;
&:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
}
.btn-edit {
border-color: rgba(209, 168, 120, 0.4);
color: #d1a878;
&:hover {
background: rgba(209, 168, 120, 0.15);
}
}
.btn-ai {
&.active {
background: rgba(168, 85, 247, 0.2);
border-color: rgba(168, 85, 247, 0.5);
color: #d8b4fe;
}
}

View File

@@ -0,0 +1,80 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular';
import { CharacterService } from '../../../services/character.service';
import { CampaignService } from '../../../services/campaign.service';
import { GameSystemService } from '../../../services/game-system.service';
import { TemplateField } from '../../../services/template.model';
import { Character } from '../../../services/character.model';
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
/**
* Vue lecture seule "WorldAnvil" d'une fiche PJ.
* Route : /campaigns/:campaignId/characters/:characterId
*/
@Component({
selector: 'app-character-view',
standalone: true,
imports: [CommonModule, LucideAngularModule, PersonaViewComponent, AiChatDrawerComponent],
templateUrl: './character-view.component.html',
styleUrls: ['./character-view.component.scss']
})
export class CharacterViewComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Edit3 = Edit3;
readonly Sparkles = Sparkles;
campaignId: string | null = null;
characterId: string | null = null;
character: Character | null = null;
templateFields: TemplateField[] = [];
chatOpen = false;
toggleChat(): void { this.chatOpen = !this.chatOpen; }
constructor(
private route: ActivatedRoute,
private router: Router,
private service: CharacterService,
private campaignService: CampaignService,
private gameSystemService: GameSystemService
) {}
ngOnInit(): void {
const params = this.route.snapshot.paramMap;
this.campaignId = params.get('campaignId');
this.characterId = params.get('characterId');
if (this.characterId) {
this.service.getById(this.characterId).subscribe({
next: c => { this.character = c; },
error: () => this.back()
});
}
if (this.campaignId) {
this.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
if (camp.gameSystemId) {
this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => {
this.templateFields = gs.characterTemplate ?? [];
});
}
});
}
}
edit(): void {
if (this.campaignId && this.characterId) {
this.router.navigate(['/campaigns', this.campaignId, 'characters', this.characterId, 'edit']);
}
}
back(): void {
if (this.campaignId) {
this.router.navigate(['/campaigns', this.campaignId]);
} else {
this.router.navigate(['/campaigns']);
}
}
}

View File

@@ -35,19 +35,26 @@
/> />
</div> </div>
<div class="field-row"> <div class="field-row image-row">
<div class="field"> <div class="field portrait-field">
<label>Portrait (ID image)</label> <label>Portrait</label>
<input type="text" [(ngModel)]="portraitImageId" name="portraitImageId" placeholder="ID de l'image portrait" /> <app-single-image-picker
[imageId]="portraitImageId"
aspectRatio="1 / 1"
hint="Carre conseille (400×400)."
(imageIdChange)="portraitImageId = $event">
</app-single-image-picker>
</div> </div>
<div class="field"> <div class="field header-field">
<label>Bandeau / Header (ID image)</label> <label>Bandeau / Header</label>
<input type="text" [(ngModel)]="headerImageId" name="headerImageId" placeholder="ID de l'image bandeau" /> <app-single-image-picker
[imageId]="headerImageId"
aspectRatio="3 / 1"
hint="Format paysage conseille (1200×400)."
(imageIdChange)="headerImageId = $event">
</app-single-image-picker>
</div> </div>
</div> </div>
<p class="hint">
Les portraits et bandeaux acceptent un ID d'image (MVP). Picker visuel a venir.
</p>
<div class="template-fields"> <div class="template-fields">
<app-dynamic-fields-form <app-dynamic-fields-form

View File

@@ -6,9 +6,10 @@ import { LucideAngularModule, Save, ArrowLeft, Drama, Trash2, Sparkles } from 'l
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { GameSystemService } from '../../../services/game-system.service'; import { GameSystemService } from '../../../services/game-system.service';
import { TemplateField } from '../../../services/template-field.model'; import { TemplateField } from '../../../services/template.model';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component'; import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component'; import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
import { SingleImagePickerComponent } from '../../../shared/single-image-picker/single-image-picker.component';
/** /**
* Editeur plein ecran d'une fiche de PNJ. * Editeur plein ecran d'une fiche de PNJ.
@@ -19,7 +20,7 @@ import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/
@Component({ @Component({
selector: 'app-npc-edit', selector: 'app-npc-edit',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent], imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent],
templateUrl: './npc-edit.component.html', templateUrl: './npc-edit.component.html',
styleUrls: ['./npc-edit.component.scss'] styleUrls: ['./npc-edit.component.scss']
}) })
@@ -43,8 +44,8 @@ export class NpcEditComponent implements OnInit {
npcId: string | null = null; npcId: string | null = null;
name = ''; name = '';
portraitImageId = ''; portraitImageId: string | null = null;
headerImageId = ''; headerImageId: string | null = null;
values: Record<string, string> = {}; values: Record<string, string> = {};
imageValues: Record<string, string[]> = {}; imageValues: Record<string, string[]> = {};
templateFields: TemplateField[] = []; templateFields: TemplateField[] = [];
@@ -71,8 +72,8 @@ export class NpcEditComponent implements OnInit {
this.service.getById(this.npcId).subscribe({ this.service.getById(this.npcId).subscribe({
next: (n) => { next: (n) => {
this.name = n.name; this.name = n.name;
this.portraitImageId = n.portraitImageId ?? ''; this.portraitImageId = n.portraitImageId ?? null;
this.headerImageId = n.headerImageId ?? ''; this.headerImageId = n.headerImageId ?? null;
this.values = n.values ?? {}; this.values = n.values ?? {};
this.imageValues = n.imageValues ?? {}; this.imageValues = n.imageValues ?? {};
this.order = n.order ?? 0; this.order = n.order ?? 0;
@@ -102,8 +103,8 @@ export class NpcEditComponent implements OnInit {
if (!this.name.trim() || !this.campaignId) return; if (!this.name.trim() || !this.campaignId) return;
const payload = { const payload = {
name: this.name.trim(), name: this.name.trim(),
portraitImageId: this.portraitImageId.trim() || null, portraitImageId: this.portraitImageId,
headerImageId: this.headerImageId.trim() || null, headerImageId: this.headerImageId,
values: this.values, values: this.values,
imageValues: this.imageValues, imageValues: this.imageValues,
campaignId: this.campaignId campaignId: this.campaignId

View File

@@ -0,0 +1,28 @@
<div class="nv-page">
<div class="nv-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour
</button>
<span class="spacer"></span>
<button class="btn-ai" (click)="toggleChat()" [class.active]="chatOpen" *ngIf="npcId">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
<button class="btn-edit" (click)="edit()">
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon>
Editer
</button>
</div>
<app-persona-view [persona]="npc" [templateFields]="templateFields"></app-persona-view>
</div>
<app-ai-chat-drawer
*ngIf="npcId && campaignId"
[campaignId]="campaignId"
entityType="npc"
[entityId]="npcId"
[isOpen]="chatOpen"
(close)="chatOpen = false">
</app-ai-chat-drawer>

View File

@@ -0,0 +1,51 @@
.nv-page {
padding: 16px 0 48px;
min-height: 100vh;
}
.nv-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 32px 16px;
max-width: 1100px;
margin: 0 auto;
.spacer { flex: 1; }
}
.btn-back,
.btn-edit,
.btn-ai {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 4px;
color: #d1d5db;
font-size: 0.85rem;
cursor: pointer;
transition: all 120ms;
&:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
}
.btn-edit {
border-color: rgba(209, 168, 120, 0.4);
color: #d1a878;
&:hover {
background: rgba(209, 168, 120, 0.15);
}
}
.btn-ai.active {
background: rgba(168, 85, 247, 0.2);
border-color: rgba(168, 85, 247, 0.5);
color: #d8b4fe;
}

View File

@@ -0,0 +1,80 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Edit3, 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.model';
import { Npc } from '../../../services/npc.model';
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
/**
* Vue lecture seule "WorldAnvil" d'une fiche PNJ.
* Route : /campaigns/:campaignId/npcs/:npcId
*/
@Component({
selector: 'app-npc-view',
standalone: true,
imports: [CommonModule, LucideAngularModule, PersonaViewComponent, AiChatDrawerComponent],
templateUrl: './npc-view.component.html',
styleUrls: ['./npc-view.component.scss']
})
export class NpcViewComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Edit3 = Edit3;
readonly Sparkles = Sparkles;
campaignId: string | null = null;
npcId: string | null = null;
npc: Npc | null = null;
templateFields: TemplateField[] = [];
chatOpen = false;
toggleChat(): void { this.chatOpen = !this.chatOpen; }
constructor(
private route: ActivatedRoute,
private router: Router,
private service: NpcService,
private campaignService: CampaignService,
private gameSystemService: GameSystemService
) {}
ngOnInit(): void {
const params = this.route.snapshot.paramMap;
this.campaignId = params.get('campaignId');
this.npcId = params.get('npcId');
if (this.npcId) {
this.service.getById(this.npcId).subscribe({
next: n => { this.npc = n; },
error: () => this.back()
});
}
if (this.campaignId) {
this.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
if (camp.gameSystemId) {
this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => {
this.templateFields = gs.npcTemplate ?? [];
});
}
});
}
}
edit(): void {
if (this.campaignId && this.npcId) {
this.router.navigate(['/campaigns', this.campaignId, 'npcs', this.npcId, 'edit']);
}
}
back(): void {
if (this.campaignId) {
this.router.navigate(['/campaigns', this.campaignId]);
} else {
this.router.navigate(['/campaigns']);
}
}
}

View File

@@ -4,7 +4,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Save, ArrowLeft, Dices, Plus, Trash2, ChevronDown, ChevronRight } from 'lucide-angular'; import { LucideAngularModule, Save, ArrowLeft, Dices, Plus, Trash2, ChevronDown, ChevronRight } from 'lucide-angular';
import { GameSystemService } from '../../services/game-system.service'; import { GameSystemService } from '../../services/game-system.service';
import { TemplateField } from '../../services/template-field.model'; import { TemplateField } from '../../services/template.model';
import { TemplateFieldsEditorComponent } from '../../shared/template-fields-editor/template-fields-editor.component'; import { TemplateFieldsEditorComponent } from '../../shared/template-fields-editor/template-fields-editor.component';
/** /**

View File

@@ -1,4 +1,4 @@
import { TemplateField } from './template-field.model'; import { TemplateField } from './template.model';
/** /**
* Interface TypeScript pour GameSystemDTO (jumeau du DTO Java). * Interface TypeScript pour GameSystemDTO (jumeau du DTO Java).

View File

@@ -1,15 +0,0 @@
/**
* 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;
}

View File

@@ -1,11 +1,12 @@
// Interfaces TypeScript pour TemplateDTO (Backend Java). // Interfaces TypeScript pour TemplateDTO (Backend Java).
/** /**
* Type d'un champ de Template. Miroir de com.loremind.domain.lorecontext.FieldType. * Type d'un champ de Template. Miroir de com.loremind.domain.shared.template.FieldType.
* - 'TEXT' : champ textuel libre (rendu en textarea) * - 'TEXT' : champ textuel libre (rendu en textarea)
* - 'IMAGE' : galerie d'images (rendu en app-image-gallery) * - 'IMAGE' : galerie d'images (rendu en app-image-gallery)
* - 'NUMBER' : valeur numerique (rendu en input number)
*/ */
export type FieldType = 'TEXT' | 'IMAGE'; export type FieldType = 'TEXT' | 'IMAGE' | 'NUMBER';
/** /**
* Variante de rendu pour un champ IMAGE. Miroir de * Variante de rendu pour un champ IMAGE. Miroir de

View File

@@ -21,16 +21,13 @@
placeholder="0" placeholder="0"
/> />
<div *ngSwitchCase="'IMAGE'" class="image-mvp"> <app-image-gallery
<input *ngSwitchCase="'IMAGE'"
type="text" [editable]="true"
[ngModel]="imageCsvCache[f.name] || ''" [layout]="f.layout || 'GALLERY'"
(ngModelChange)="onImageCsvChange(f, $event)" [imageIds]="imagesFor(f)"
[name]="'img-' + f.name" (imageIdsChange)="onImageIdsChange(f, $event)">
placeholder="IDs d'images separes par des virgules (MVP)" </app-image-gallery>
/>
<small class="hint">Layout : {{ f.layout || 'GALLERY' }}. Picker dedie a venir.</small>
</div>
</ng-container> </ng-container>
</div> </div>
</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 { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; 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. * 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 * - values : Record<champName, string> pour les types TEXT et NUMBER
* - imageValues : Record<champName, string[]> pour le type IMAGE * - imageValues : Record<champName, string[]> pour le type IMAGE
* *
* Emet `valuesChange` et `imageValuesChange` a chaque modification. * Pour les champs IMAGE, delegue au composant <app-image-gallery editable>
* * qui gere l'upload, la suppression et le respect du layout.
* 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({ @Component({
selector: 'app-dynamic-fields-form', selector: 'app-dynamic-fields-form',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule], imports: [CommonModule, FormsModule, ImageGalleryComponent],
templateUrl: './dynamic-fields-form.component.html', templateUrl: './dynamic-fields-form.component.html',
styleUrls: ['./dynamic-fields-form.component.scss'] styleUrls: ['./dynamic-fields-form.component.scss']
}) })
export class DynamicFieldsFormComponent implements OnChanges { export class DynamicFieldsFormComponent {
@Input() fields: TemplateField[] = []; @Input() fields: TemplateField[] = [];
@Input() values: Record<string, string> = {}; @Input() values: Record<string, string> = {};
@Input() imageValues: 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() valuesChange = new EventEmitter<Record<string, string>>();
@Output() imageValuesChange = 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 { onTextChange(field: TemplateField, value: string): void {
this.values = { ...this.values, [field.name]: value }; this.values = { ...this.values, [field.name]: value };
this.valuesChange.emit(this.values); this.valuesChange.emit(this.values);
} }
onImageCsvChange(field: TemplateField, csv: string): void { onImageIdsChange(field: TemplateField, ids: 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.imageValues = { ...this.imageValues, [field.name]: ids };
this.imageValuesChange.emit(this.imageValues); this.imageValuesChange.emit(this.imageValues);
} }
imagesFor(field: TemplateField): string[] {
return this.imageValues[field.name] ?? [];
}
trackByName = (_: number, f: TemplateField) => f.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 { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; 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 } 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. * Editeur reutilisable d'une liste de TemplateField.