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 @@

-
+
{{ character.name }} @@ -123,7 +123,7 @@
-
+
{{ 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 @@ />
-
-
- - +
+
+ + +
-
- - +
+ + +
-

- 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 @@ +
+
+ + + + +
+ + +
+ + + 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 @@ />
-
-
- - +
+
+ + +
-
- - +
+ + +
-

- 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 @@ +
+
+ + + + +
+ + +
+ + + 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.