diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/CharacterNpcMarkdownBackfill.java b/core/src/main/java/com/loremind/infrastructure/persistence/CharacterNpcMarkdownBackfill.java
new file mode 100644
index 0000000..497483f
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/persistence/CharacterNpcMarkdownBackfill.java
@@ -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.
+ *
+ * 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). La colonne
+ * {@code markdown_content} subsiste car Hibernate ddl-auto=update ne drop pas.
+ *
+ * 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.
+ *
+ * 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;
+ }
+}
diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/GameSystemSeeder.java b/core/src/main/java/com/loremind/infrastructure/persistence/GameSystemSeeder.java
index c0b1216..7cf3f8a 100644
--- a/core/src/main/java/com/loremind/infrastructure/persistence/GameSystemSeeder.java
+++ b/core/src/main/java/com/loremind/infrastructure/persistence/GameSystemSeeder.java
@@ -2,6 +2,8 @@ package com.loremind.infrastructure.persistence;
import com.loremind.domain.gamesystemcontext.GameSystem;
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.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
@@ -23,6 +25,10 @@ import java.util.List;
*
* 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).
+ *
+ * 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
public class GameSystemSeeder {
@@ -37,15 +43,37 @@ public class GameSystemSeeder {
@EventListener(ApplicationReadyEvent.class)
public void seedIfEmpty() {
- if (!gameSystemRepository.findAll().isEmpty()) {
- log.debug("GameSystem seed skipped — table non vide.");
+ List existing = gameSystemRepository.findAll();
+ 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;
}
- log.info("Seed initial des GameSystems (table vide)...");
- for (GameSystem gs : defaultSystems()) {
- gameSystemRepository.save(gs);
+ log.debug("GameSystem seed skipped — table non vide. Backfill templates si necessaire...");
+ backfillEmptyTemplates(existing);
+ }
+
+ /**
+ * 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 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 defaultSystems() {
@@ -56,6 +84,8 @@ public class GameSystemSeeder {
.author("LoreMind seed")
.isPublic(false)
.rulesMarkdown(NIMBLE_RULES)
+ .characterTemplate(nimbleCharacterTemplate())
+ .npcTemplate(genericNpcTemplate())
.build(),
GameSystem.builder()
.name("D&D 5e SRD (extrait)")
@@ -63,6 +93,8 @@ public class GameSystemSeeder {
.author("LoreMind seed")
.isPublic(false)
.rulesMarkdown(DND_SRD_RULES)
+ .characterTemplate(dndCharacterTemplate())
+ .npcTemplate(genericNpcTemplate())
.build(),
GameSystem.builder()
.name("Homebrew Exemple")
@@ -70,10 +102,70 @@ public class GameSystemSeeder {
.author("LoreMind seed")
.isPublic(false)
.rulesMarkdown(HOMEBREW_EXAMPLE)
+ .characterTemplate(genericCharacterTemplate())
+ .npcTemplate(genericNpcTemplate())
.build()
);
}
+ // --- Templates par defaut ---------------------------------------------
+
+ /** Template generique PJ — utilise pour Homebrew, backfill, et fallback. */
+ private static List 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 genericNpcTemplate() {
+ return List.of(
+ TemplateField.text("Apparence"),
+ TemplateField.text("Motivation"),
+ TemplateField.text("Faction"),
+ TemplateField.text("Notes MJ")
+ );
+ }
+
+ private static List 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 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 = """
Système Nimble — résolution rapide, narration fluide, peu de tableaux. Agnostique (aucun univers imposé).
diff --git a/web/src/app/app.routes.ts b/web/src/app/app.routes.ts
index 8d5b61c..b23e94e 100644
--- a/web/src/app/app.routes.ts
+++ b/web/src/app/app.routes.ts
@@ -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/: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', 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/: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/: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) },
diff --git a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html
index 247cede..0a07e05 100644
--- a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html
+++ b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html
@@ -90,7 +90,7 @@
0">
-
+
{{ character.name }}
@@ -123,7 +123,7 @@
0">
-
+
{{ npc.name }}
diff --git a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts
index f0c3b98..36c3518 100644
--- a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts
+++ b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.ts
@@ -194,6 +194,17 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
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 {
if (!this.campaign) return;
this.router.navigate(['/campaigns', this.campaign.id, 'arcs', 'create']);
diff --git a/web/src/app/campaigns/character/character-edit/character-edit.component.html b/web/src/app/campaigns/character/character-edit/character-edit.component.html
index 3cc8136..9cf292d 100644
--- a/web/src/app/campaigns/character/character-edit/character-edit.component.html
+++ b/web/src/app/campaigns/character/character-edit/character-edit.component.html
@@ -35,29 +35,26 @@
/>
-
-
-
Portrait (ID image)
-
+
+
-
- Bandeau / Header (ID image)
-
+
-
- Les portraits et bandeaux acceptent un ID d'image (MVP). Picker visuel a venir.
-
= {};
imageValues: Record = {};
templateFields: TemplateField[] = [];
@@ -76,8 +77,8 @@ export class CharacterEditComponent implements OnInit {
this.service.getById(this.characterId).subscribe({
next: (c) => {
this.name = c.name;
- this.portraitImageId = c.portraitImageId ?? '';
- this.headerImageId = c.headerImageId ?? '';
+ this.portraitImageId = c.portraitImageId ?? null;
+ this.headerImageId = c.headerImageId ?? null;
this.values = c.values ?? {};
this.imageValues = c.imageValues ?? {};
this.order = c.order ?? 0;
@@ -107,8 +108,8 @@ export class CharacterEditComponent implements OnInit {
if (!this.name.trim() || !this.campaignId) return;
const payload = {
name: this.name.trim(),
- portraitImageId: this.portraitImageId.trim() || null,
- headerImageId: this.headerImageId.trim() || null,
+ portraitImageId: this.portraitImageId,
+ headerImageId: this.headerImageId,
values: this.values,
imageValues: this.imageValues,
campaignId: this.campaignId
diff --git a/web/src/app/campaigns/character/character-view/character-view.component.html b/web/src/app/campaigns/character/character-view/character-view.component.html
new file mode 100644
index 0000000..1c72258
--- /dev/null
+++ b/web/src/app/campaigns/character/character-view/character-view.component.html
@@ -0,0 +1,28 @@
+
+
+
+
+ Retour
+
+
+
+
+ Assistant IA
+
+
+
+ Editer
+
+
+
+
+
+
+
+
diff --git a/web/src/app/campaigns/character/character-view/character-view.component.scss b/web/src/app/campaigns/character/character-view/character-view.component.scss
new file mode 100644
index 0000000..3b7addf
--- /dev/null
+++ b/web/src/app/campaigns/character/character-view/character-view.component.scss
@@ -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;
+ }
+}
diff --git a/web/src/app/campaigns/character/character-view/character-view.component.ts b/web/src/app/campaigns/character/character-view/character-view.component.ts
new file mode 100644
index 0000000..6f666ea
--- /dev/null
+++ b/web/src/app/campaigns/character/character-view/character-view.component.ts
@@ -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']);
+ }
+ }
+}
diff --git a/web/src/app/campaigns/npc/npc-edit/npc-edit.component.html b/web/src/app/campaigns/npc/npc-edit/npc-edit.component.html
index 728ea4a..d63061f 100644
--- a/web/src/app/campaigns/npc/npc-edit/npc-edit.component.html
+++ b/web/src/app/campaigns/npc/npc-edit/npc-edit.component.html
@@ -35,19 +35,26 @@
/>
-
-
-
Portrait (ID image)
-
+
+
-
- Bandeau / Header (ID image)
-
+
-
- Les portraits et bandeaux acceptent un ID d'image (MVP). Picker visuel a venir.
-
= {};
imageValues: Record = {};
templateFields: TemplateField[] = [];
@@ -71,8 +72,8 @@ export class NpcEditComponent implements OnInit {
this.service.getById(this.npcId).subscribe({
next: (n) => {
this.name = n.name;
- this.portraitImageId = n.portraitImageId ?? '';
- this.headerImageId = n.headerImageId ?? '';
+ this.portraitImageId = n.portraitImageId ?? null;
+ this.headerImageId = n.headerImageId ?? null;
this.values = n.values ?? {};
this.imageValues = n.imageValues ?? {};
this.order = n.order ?? 0;
@@ -102,8 +103,8 @@ export class NpcEditComponent implements OnInit {
if (!this.name.trim() || !this.campaignId) return;
const payload = {
name: this.name.trim(),
- portraitImageId: this.portraitImageId.trim() || null,
- headerImageId: this.headerImageId.trim() || null,
+ portraitImageId: this.portraitImageId,
+ headerImageId: this.headerImageId,
values: this.values,
imageValues: this.imageValues,
campaignId: this.campaignId
diff --git a/web/src/app/campaigns/npc/npc-view/npc-view.component.html b/web/src/app/campaigns/npc/npc-view/npc-view.component.html
new file mode 100644
index 0000000..b94f5a0
--- /dev/null
+++ b/web/src/app/campaigns/npc/npc-view/npc-view.component.html
@@ -0,0 +1,28 @@
+
+
+
+
+ Retour
+
+
+
+
+ Assistant IA
+
+
+
+ Editer
+
+
+
+
+
+
+
+
diff --git a/web/src/app/campaigns/npc/npc-view/npc-view.component.scss b/web/src/app/campaigns/npc/npc-view/npc-view.component.scss
new file mode 100644
index 0000000..cf9f3b5
--- /dev/null
+++ b/web/src/app/campaigns/npc/npc-view/npc-view.component.scss
@@ -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;
+}
diff --git a/web/src/app/campaigns/npc/npc-view/npc-view.component.ts b/web/src/app/campaigns/npc/npc-view/npc-view.component.ts
new file mode 100644
index 0000000..50be782
--- /dev/null
+++ b/web/src/app/campaigns/npc/npc-view/npc-view.component.ts
@@ -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']);
+ }
+ }
+}
diff --git a/web/src/app/game-systems/game-system-edit/game-system-edit.component.ts b/web/src/app/game-systems/game-system-edit/game-system-edit.component.ts
index bcbfe2a..763e05d 100644
--- a/web/src/app/game-systems/game-system-edit/game-system-edit.component.ts
+++ b/web/src/app/game-systems/game-system-edit/game-system-edit.component.ts
@@ -4,7 +4,7 @@ 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 { TemplateField } from '../../services/template.model';
import { TemplateFieldsEditorComponent } from '../../shared/template-fields-editor/template-fields-editor.component';
/**
diff --git a/web/src/app/services/game-system.model.ts b/web/src/app/services/game-system.model.ts
index 88730b8..3ddd678 100644
--- a/web/src/app/services/game-system.model.ts
+++ b/web/src/app/services/game-system.model.ts
@@ -1,4 +1,4 @@
-import { TemplateField } from './template-field.model';
+import { TemplateField } from './template.model';
/**
* Interface TypeScript pour GameSystemDTO (jumeau du DTO Java).
diff --git a/web/src/app/services/template-field.model.ts b/web/src/app/services/template-field.model.ts
deleted file mode 100644
index e8baae3..0000000
--- a/web/src/app/services/template-field.model.ts
+++ /dev/null
@@ -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;
-}
diff --git a/web/src/app/services/template.model.ts b/web/src/app/services/template.model.ts
index 47d1894..0f241d9 100644
--- a/web/src/app/services/template.model.ts
+++ b/web/src/app/services/template.model.ts
@@ -1,11 +1,12 @@
// Interfaces TypeScript pour TemplateDTO (Backend Java).
/**
- * Type d'un champ de Template. Miroir de com.loremind.domain.lorecontext.FieldType.
- * - 'TEXT' : champ textuel libre (rendu en textarea)
- * - 'IMAGE' : galerie d'images (rendu en app-image-gallery)
+ * Type d'un champ de Template. Miroir de com.loremind.domain.shared.template.FieldType.
+ * - 'TEXT' : champ textuel libre (rendu en textarea)
+ * - 'IMAGE' : galerie d'images (rendu en app-image-gallery)
+ * - 'NUMBER' : valeur numerique (rendu en input number)
*/
-export type FieldType = 'TEXT' | 'IMAGE';
+export type FieldType = 'TEXT' | 'IMAGE' | 'NUMBER';
/**
* Variante de rendu pour un champ IMAGE. Miroir de
diff --git a/web/src/app/shared/dynamic-fields-form/dynamic-fields-form.component.html b/web/src/app/shared/dynamic-fields-form/dynamic-fields-form.component.html
index 3918532..439eb2c 100644
--- a/web/src/app/shared/dynamic-fields-form/dynamic-fields-form.component.html
+++ b/web/src/app/shared/dynamic-fields-form/dynamic-fields-form.component.html
@@ -21,16 +21,13 @@
placeholder="0"
/>
-
-
- Layout : {{ f.layout || 'GALLERY' }}. Picker dedie a venir.
-
+
+
diff --git a/web/src/app/shared/dynamic-fields-form/dynamic-fields-form.component.ts b/web/src/app/shared/dynamic-fields-form/dynamic-fields-form.component.ts
index 60a42e0..07c6fea 100644
--- a/web/src/app/shared/dynamic-fields-form/dynamic-fields-form.component.ts
+++ b/web/src/app/shared/dynamic-fields-form/dynamic-fields-form.component.ts
@@ -1,7 +1,8 @@
-import { Component, EventEmitter, Input, Output, OnChanges, SimpleChanges } from '@angular/core';
+import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
-import { TemplateField } from '../../services/template-field.model';
+import { TemplateField } from '../../services/template.model';
+import { ImageGalleryComponent } from '../image-gallery/image-gallery.component';
/**
* Formulaire dynamique pilote par une liste de TemplateField.
@@ -11,19 +12,17 @@ import { TemplateField } from '../../services/template-field.model';
* - values : Record
pour les types TEXT et NUMBER
* - imageValues : Record pour le type IMAGE
*
- * Emet `valuesChange` et `imageValuesChange` a chaque modification.
- *
- * Pour les champs IMAGE le MVP affiche une simple textarea CSV d'IDs (le picker
- * d'images dedie sera branche plus tard, hors scope de la refonte template).
+ * Pour les champs IMAGE, delegue au composant
+ * qui gere l'upload, la suppression et le respect du layout.
*/
@Component({
selector: 'app-dynamic-fields-form',
standalone: true,
- imports: [CommonModule, FormsModule],
+ imports: [CommonModule, FormsModule, ImageGalleryComponent],
templateUrl: './dynamic-fields-form.component.html',
styleUrls: ['./dynamic-fields-form.component.scss']
})
-export class DynamicFieldsFormComponent implements OnChanges {
+export class DynamicFieldsFormComponent {
@Input() fields: TemplateField[] = [];
@Input() values: Record = {};
@Input() imageValues: Record = {};
@@ -31,32 +30,19 @@ export class DynamicFieldsFormComponent implements OnChanges {
@Output() valuesChange = new EventEmitter>();
@Output() imageValuesChange = new EventEmitter>();
- /** Cache de strings CSV pour edition d'imageValues sans serialisation continue. */
- imageCsvCache: Record = {};
-
- ngOnChanges(changes: SimpleChanges): void {
- if (changes['imageValues'] || changes['fields']) {
- this.imageCsvCache = {};
- for (const f of this.fields) {
- if (f.type === 'IMAGE') {
- const list = this.imageValues[f.name] ?? [];
- this.imageCsvCache[f.name] = list.join(', ');
- }
- }
- }
- }
-
onTextChange(field: TemplateField, value: string): void {
this.values = { ...this.values, [field.name]: value };
this.valuesChange.emit(this.values);
}
- onImageCsvChange(field: TemplateField, csv: string): void {
- this.imageCsvCache[field.name] = csv;
- const ids = csv.split(',').map(s => s.trim()).filter(s => s.length > 0);
+ onImageIdsChange(field: TemplateField, ids: string[]): void {
this.imageValues = { ...this.imageValues, [field.name]: ids };
this.imageValuesChange.emit(this.imageValues);
}
+ imagesFor(field: TemplateField): string[] {
+ return this.imageValues[field.name] ?? [];
+ }
+
trackByName = (_: number, f: TemplateField) => f.name;
}
diff --git a/web/src/app/shared/persona-view/persona-view.component.html b/web/src/app/shared/persona-view/persona-view.component.html
new file mode 100644
index 0000000..46c8970
--- /dev/null
+++ b/web/src/app/shared/persona-view/persona-view.component.html
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ persona.name }}
+
{{ subtitle }}
+
+
+
+
+
+
+
+ {{ f.name }}
+ {{ f.value }}
+
+
+
+
+
+
+
+
+ {{ f.name }}
+
+
+ {{ firstParagraph(f.value) }}
+
+
+ {{ restAfterFirstParagraph(f.value) }}
+
+
+
+
+
+
+
+ {{ img.field.name }}
+
+
+
+
+
+
+
+
Cette fiche est encore vide.
+
+
+
diff --git a/web/src/app/shared/persona-view/persona-view.component.scss b/web/src/app/shared/persona-view/persona-view.component.scss
new file mode 100644
index 0000000..919fe16
--- /dev/null
+++ b/web/src/app/shared/persona-view/persona-view.component.scss
@@ -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;
+ }
+}
diff --git a/web/src/app/shared/persona-view/persona-view.component.ts b/web/src/app/shared/persona-view/persona-view.component.ts
new file mode 100644
index 0000000..117fa9d
--- /dev/null
+++ b/web/src/app/shared/persona-view/persona-view.component.ts
@@ -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;
+ imageValues?: Record;
+}
+
+@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();
+ }
+}
diff --git a/web/src/app/shared/single-image-picker/single-image-picker.component.html b/web/src/app/shared/single-image-picker/single-image-picker.component.html
new file mode 100644
index 0000000..caa416e
--- /dev/null
+++ b/web/src/app/shared/single-image-picker/single-image-picker.component.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ hint }}
+
diff --git a/web/src/app/shared/single-image-picker/single-image-picker.component.scss b/web/src/app/shared/single-image-picker/single-image-picker.component.scss
new file mode 100644
index 0000000..f5f2286
--- /dev/null
+++ b/web/src/app/shared/single-image-picker/single-image-picker.component.scss
@@ -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);
+}
diff --git a/web/src/app/shared/single-image-picker/single-image-picker.component.ts b/web/src/app/shared/single-image-picker/single-image-picker.component.ts
new file mode 100644
index 0000000..6ca11f4
--- /dev/null
+++ b/web/src/app/shared/single-image-picker/single-image-picker.component.ts
@@ -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 :
+ *
+ *
+ *
+ * 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();
+
+ 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);
+ }
+}
diff --git a/web/src/app/shared/template-fields-editor/template-fields-editor.component.ts b/web/src/app/shared/template-fields-editor/template-fields-editor.component.ts
index b701deb..eaf7d60 100644
--- a/web/src/app/shared/template-fields-editor/template-fields-editor.component.ts
+++ b/web/src/app/shared/template-fields-editor/template-fields-editor.component.ts
@@ -2,7 +2,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Plus, Trash2, ArrowUp, ArrowDown, Type, Image as ImageIcon, Hash } from 'lucide-angular';
-import { TemplateField, FieldType, ImageLayout } from '../../services/template-field.model';
+import { TemplateField, FieldType, ImageLayout } from '../../services/template.model';
/**
* Editeur reutilisable d'une liste de TemplateField.