Mise en place du picker d'image pour la partie header / illustration des fiches personnage
Migration pour l'ancienne partie des fiches perso vers les nouvelles pages Vue retravaillée pour les fiches perso
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
* <p>
|
||||
* 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).
|
||||
* <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
|
||||
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.");
|
||||
return;
|
||||
}
|
||||
List<GameSystem> 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.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<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++;
|
||||
}
|
||||
}
|
||||
if (patched > 0) log.info("Backfill templates GameSystem : {} systeme(s) patche(s).", patched);
|
||||
}
|
||||
|
||||
private List<GameSystem> 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<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 = """
|
||||
Système Nimble — résolution rapide, narration fluide, peu de tableaux. Agnostique (aucun univers imposé).
|
||||
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div class="character-info">
|
||||
<span class="character-name">{{ character.name }}</span>
|
||||
@@ -123,7 +123,7 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div class="character-info">
|
||||
<span class="character-name">{{ npc.name }}</span>
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -35,29 +35,26 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Portrait (ID image)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="portraitImageId"
|
||||
name="portraitImageId"
|
||||
placeholder="ID de l'image portrait"
|
||||
/>
|
||||
<div class="field-row image-row">
|
||||
<div class="field portrait-field">
|
||||
<label>Portrait</label>
|
||||
<app-single-image-picker
|
||||
[imageId]="portraitImageId"
|
||||
aspectRatio="1 / 1"
|
||||
hint="Carre conseille (400×400)."
|
||||
(imageIdChange)="portraitImageId = $event">
|
||||
</app-single-image-picker>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Bandeau / Header (ID image)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="headerImageId"
|
||||
name="headerImageId"
|
||||
placeholder="ID de l'image bandeau"
|
||||
/>
|
||||
<div class="field header-field">
|
||||
<label>Bandeau / Header</label>
|
||||
<app-single-image-picker
|
||||
[imageId]="headerImageId"
|
||||
aspectRatio="3 / 1"
|
||||
hint="Format paysage conseille (1200×400)."
|
||||
(imageIdChange)="headerImageId = $event">
|
||||
</app-single-image-picker>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">
|
||||
Les portraits et bandeaux acceptent un ID d'image (MVP). Picker visuel a venir.
|
||||
</p>
|
||||
|
||||
<div class="template-fields">
|
||||
<app-dynamic-fields-form
|
||||
|
||||
@@ -6,9 +6,10 @@ import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lu
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { CampaignService } from '../../../services/campaign.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 { 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).
|
||||
@@ -24,7 +25,7 @@ import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/
|
||||
@Component({
|
||||
selector: 'app-character-edit',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent],
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent],
|
||||
templateUrl: './character-edit.component.html',
|
||||
styleUrls: ['./character-edit.component.scss']
|
||||
})
|
||||
@@ -48,8 +49,8 @@ export class CharacterEditComponent implements OnInit {
|
||||
characterId: string | null = null;
|
||||
|
||||
name = '';
|
||||
portraitImageId = '';
|
||||
headerImageId = '';
|
||||
portraitImageId: string | null = null;
|
||||
headerImageId: string | null = null;
|
||||
values: Record<string, string> = {};
|
||||
imageValues: Record<string, string[]> = {};
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,19 +35,26 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Portrait (ID image)</label>
|
||||
<input type="text" [(ngModel)]="portraitImageId" name="portraitImageId" placeholder="ID de l'image portrait" />
|
||||
<div class="field-row image-row">
|
||||
<div class="field portrait-field">
|
||||
<label>Portrait</label>
|
||||
<app-single-image-picker
|
||||
[imageId]="portraitImageId"
|
||||
aspectRatio="1 / 1"
|
||||
hint="Carre conseille (400×400)."
|
||||
(imageIdChange)="portraitImageId = $event">
|
||||
</app-single-image-picker>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Bandeau / Header (ID image)</label>
|
||||
<input type="text" [(ngModel)]="headerImageId" name="headerImageId" placeholder="ID de l'image bandeau" />
|
||||
<div class="field header-field">
|
||||
<label>Bandeau / Header</label>
|
||||
<app-single-image-picker
|
||||
[imageId]="headerImageId"
|
||||
aspectRatio="3 / 1"
|
||||
hint="Format paysage conseille (1200×400)."
|
||||
(imageIdChange)="headerImageId = $event">
|
||||
</app-single-image-picker>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">
|
||||
Les portraits et bandeaux acceptent un ID d'image (MVP). Picker visuel a venir.
|
||||
</p>
|
||||
|
||||
<div class="template-fields">
|
||||
<app-dynamic-fields-form
|
||||
|
||||
@@ -6,9 +6,10 @@ import { LucideAngularModule, Save, ArrowLeft, Drama, Trash2, Sparkles } from 'l
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { GameSystemService } from '../../../services/game-system.service';
|
||||
import { TemplateField } from '../../../services/template-field.model';
|
||||
import { TemplateField } from '../../../services/template.model';
|
||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.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.
|
||||
@@ -19,7 +20,7 @@ import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/
|
||||
@Component({
|
||||
selector: 'app-npc-edit',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent],
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent],
|
||||
templateUrl: './npc-edit.component.html',
|
||||
styleUrls: ['./npc-edit.component.scss']
|
||||
})
|
||||
@@ -43,8 +44,8 @@ export class NpcEditComponent implements OnInit {
|
||||
npcId: string | null = null;
|
||||
|
||||
name = '';
|
||||
portraitImageId = '';
|
||||
headerImageId = '';
|
||||
portraitImageId: string | null = null;
|
||||
headerImageId: string | null = null;
|
||||
values: Record<string, string> = {};
|
||||
imageValues: Record<string, string[]> = {};
|
||||
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
|
||||
|
||||
28
web/src/app/campaigns/npc/npc-view/npc-view.component.html
Normal file
28
web/src/app/campaigns/npc/npc-view/npc-view.component.html
Normal 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>
|
||||
51
web/src/app/campaigns/npc/npc-view/npc-view.component.scss
Normal file
51
web/src/app/campaigns/npc/npc-view/npc-view.component.scss
Normal 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;
|
||||
}
|
||||
80
web/src/app/campaigns/npc/npc-view/npc-view.component.ts
Normal file
80
web/src/app/campaigns/npc/npc-view/npc-view.component.ts
Normal 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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TemplateField } from './template-field.model';
|
||||
import { TemplateField } from './template.model';
|
||||
|
||||
/**
|
||||
* Interface TypeScript pour GameSystemDTO (jumeau du DTO Java).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
// 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)
|
||||
* - '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
|
||||
|
||||
@@ -21,16 +21,13 @@
|
||||
placeholder="0"
|
||||
/>
|
||||
|
||||
<div *ngSwitchCase="'IMAGE'" class="image-mvp">
|
||||
<input
|
||||
type="text"
|
||||
[ngModel]="imageCsvCache[f.name] || ''"
|
||||
(ngModelChange)="onImageCsvChange(f, $event)"
|
||||
[name]="'img-' + f.name"
|
||||
placeholder="IDs d'images separes par des virgules (MVP)"
|
||||
/>
|
||||
<small class="hint">Layout : {{ f.layout || 'GALLERY' }}. Picker dedie a venir.</small>
|
||||
</div>
|
||||
<app-image-gallery
|
||||
*ngSwitchCase="'IMAGE'"
|
||||
[editable]="true"
|
||||
[layout]="f.layout || 'GALLERY'"
|
||||
[imageIds]="imagesFor(f)"
|
||||
(imageIdsChange)="onImageIdsChange(f, $event)">
|
||||
</app-image-gallery>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Component, EventEmitter, Input, Output, OnChanges, SimpleChanges } from '@angular/core';
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { TemplateField } from '../../services/template-field.model';
|
||||
import { TemplateField } from '../../services/template.model';
|
||||
import { ImageGalleryComponent } from '../image-gallery/image-gallery.component';
|
||||
|
||||
/**
|
||||
* Formulaire dynamique pilote par une liste de TemplateField.
|
||||
@@ -11,19 +12,17 @@ import { TemplateField } from '../../services/template-field.model';
|
||||
* - values : Record<champName, string> pour les types TEXT et NUMBER
|
||||
* - imageValues : Record<champName, string[]> pour le type IMAGE
|
||||
*
|
||||
* Emet `valuesChange` et `imageValuesChange` a chaque modification.
|
||||
*
|
||||
* Pour les champs IMAGE le MVP affiche une simple textarea CSV d'IDs (le picker
|
||||
* d'images dedie sera branche plus tard, hors scope de la refonte template).
|
||||
* Pour les champs IMAGE, delegue au composant <app-image-gallery editable>
|
||||
* qui gere l'upload, la suppression et le respect du layout.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-dynamic-fields-form',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
imports: [CommonModule, FormsModule, ImageGalleryComponent],
|
||||
templateUrl: './dynamic-fields-form.component.html',
|
||||
styleUrls: ['./dynamic-fields-form.component.scss']
|
||||
})
|
||||
export class DynamicFieldsFormComponent implements OnChanges {
|
||||
export class DynamicFieldsFormComponent {
|
||||
@Input() fields: TemplateField[] = [];
|
||||
@Input() values: Record<string, string> = {};
|
||||
@Input() imageValues: Record<string, string[]> = {};
|
||||
@@ -31,32 +30,19 @@ export class DynamicFieldsFormComponent implements OnChanges {
|
||||
@Output() valuesChange = new EventEmitter<Record<string, string>>();
|
||||
@Output() imageValuesChange = new EventEmitter<Record<string, string[]>>();
|
||||
|
||||
/** Cache de strings CSV pour edition d'imageValues sans serialisation continue. */
|
||||
imageCsvCache: Record<string, string> = {};
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['imageValues'] || changes['fields']) {
|
||||
this.imageCsvCache = {};
|
||||
for (const f of this.fields) {
|
||||
if (f.type === 'IMAGE') {
|
||||
const list = this.imageValues[f.name] ?? [];
|
||||
this.imageCsvCache[f.name] = list.join(', ');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onTextChange(field: TemplateField, value: string): void {
|
||||
this.values = { ...this.values, [field.name]: value };
|
||||
this.valuesChange.emit(this.values);
|
||||
}
|
||||
|
||||
onImageCsvChange(field: TemplateField, csv: string): void {
|
||||
this.imageCsvCache[field.name] = csv;
|
||||
const ids = csv.split(',').map(s => s.trim()).filter(s => s.length > 0);
|
||||
onImageIdsChange(field: TemplateField, ids: string[]): void {
|
||||
this.imageValues = { ...this.imageValues, [field.name]: ids };
|
||||
this.imageValuesChange.emit(this.imageValues);
|
||||
}
|
||||
|
||||
imagesFor(field: TemplateField): string[] {
|
||||
return this.imageValues[field.name] ?? [];
|
||||
}
|
||||
|
||||
trackByName = (_: number, f: TemplateField) => f.name;
|
||||
}
|
||||
|
||||
60
web/src/app/shared/persona-view/persona-view.component.html
Normal file
60
web/src/app/shared/persona-view/persona-view.component.html
Normal 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>
|
||||
228
web/src/app/shared/persona-view/persona-view.component.scss
Normal file
228
web/src/app/shared/persona-view/persona-view.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
88
web/src/app/shared/persona-view/persona-view.component.ts
Normal file
88
web/src/app/shared/persona-view/persona-view.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user