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.GameSystem;
|
||||||
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
|
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
|
||||||
|
import com.loremind.domain.shared.template.ImageLayout;
|
||||||
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
@@ -23,6 +25,10 @@ import java.util.List;
|
|||||||
* <p>
|
* <p>
|
||||||
* Idempotence : ne seed qu'une fois. Si l'utilisateur supprime un ruleset seedé,
|
* Idempotence : ne seed qu'une fois. Si l'utilisateur supprime un ruleset seedé,
|
||||||
* il ne revient pas au redémarrage — c'est voulu (respect du choix utilisateur).
|
* il ne revient pas au redémarrage — c'est voulu (respect du choix utilisateur).
|
||||||
|
* <p>
|
||||||
|
* Backfill 2026-04-30 : pour les GameSystems existants (avant la refonte
|
||||||
|
* template-based), on remplit aussi les templates PJ/PNJ par defaut s'ils
|
||||||
|
* sont vides — sinon les fiches restent inutilisables.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class GameSystemSeeder {
|
public class GameSystemSeeder {
|
||||||
@@ -37,15 +43,37 @@ public class GameSystemSeeder {
|
|||||||
|
|
||||||
@EventListener(ApplicationReadyEvent.class)
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
public void seedIfEmpty() {
|
public void seedIfEmpty() {
|
||||||
if (!gameSystemRepository.findAll().isEmpty()) {
|
List<GameSystem> existing = gameSystemRepository.findAll();
|
||||||
log.debug("GameSystem seed skipped — table non vide.");
|
if (existing.isEmpty()) {
|
||||||
|
log.info("Seed initial des GameSystems (table vide)...");
|
||||||
|
for (GameSystem gs : defaultSystems()) {
|
||||||
|
gameSystemRepository.save(gs);
|
||||||
|
}
|
||||||
|
log.info("GameSystems seedés : {}", defaultSystems().size());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
log.info("Seed initial des GameSystems (table vide)...");
|
log.debug("GameSystem seed skipped — table non vide. Backfill templates si necessaire...");
|
||||||
for (GameSystem gs : defaultSystems()) {
|
backfillEmptyTemplates(existing);
|
||||||
gameSystemRepository.save(gs);
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backfill idempotent : pour chaque GameSystem existant ou les deux templates
|
||||||
|
* sont vides (PJ ET PNJ), injecte le template generique. Si l'utilisateur a
|
||||||
|
* deja personnalise au moins un des deux, on ne touche a rien.
|
||||||
|
*/
|
||||||
|
private void backfillEmptyTemplates(List<GameSystem> systems) {
|
||||||
|
int patched = 0;
|
||||||
|
for (GameSystem gs : systems) {
|
||||||
|
boolean charEmpty = gs.getCharacterTemplate() == null || gs.getCharacterTemplate().isEmpty();
|
||||||
|
boolean npcEmpty = gs.getNpcTemplate() == null || gs.getNpcTemplate().isEmpty();
|
||||||
|
if (charEmpty && npcEmpty) {
|
||||||
|
gs.replaceCharacterTemplate(genericCharacterTemplate());
|
||||||
|
gs.replaceNpcTemplate(genericNpcTemplate());
|
||||||
|
gameSystemRepository.save(gs);
|
||||||
|
patched++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
log.info("GameSystems seedés : {}", defaultSystems().size());
|
if (patched > 0) log.info("Backfill templates GameSystem : {} systeme(s) patche(s).", patched);
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<GameSystem> defaultSystems() {
|
private List<GameSystem> defaultSystems() {
|
||||||
@@ -56,6 +84,8 @@ public class GameSystemSeeder {
|
|||||||
.author("LoreMind seed")
|
.author("LoreMind seed")
|
||||||
.isPublic(false)
|
.isPublic(false)
|
||||||
.rulesMarkdown(NIMBLE_RULES)
|
.rulesMarkdown(NIMBLE_RULES)
|
||||||
|
.characterTemplate(nimbleCharacterTemplate())
|
||||||
|
.npcTemplate(genericNpcTemplate())
|
||||||
.build(),
|
.build(),
|
||||||
GameSystem.builder()
|
GameSystem.builder()
|
||||||
.name("D&D 5e SRD (extrait)")
|
.name("D&D 5e SRD (extrait)")
|
||||||
@@ -63,6 +93,8 @@ public class GameSystemSeeder {
|
|||||||
.author("LoreMind seed")
|
.author("LoreMind seed")
|
||||||
.isPublic(false)
|
.isPublic(false)
|
||||||
.rulesMarkdown(DND_SRD_RULES)
|
.rulesMarkdown(DND_SRD_RULES)
|
||||||
|
.characterTemplate(dndCharacterTemplate())
|
||||||
|
.npcTemplate(genericNpcTemplate())
|
||||||
.build(),
|
.build(),
|
||||||
GameSystem.builder()
|
GameSystem.builder()
|
||||||
.name("Homebrew Exemple")
|
.name("Homebrew Exemple")
|
||||||
@@ -70,10 +102,70 @@ public class GameSystemSeeder {
|
|||||||
.author("LoreMind seed")
|
.author("LoreMind seed")
|
||||||
.isPublic(false)
|
.isPublic(false)
|
||||||
.rulesMarkdown(HOMEBREW_EXAMPLE)
|
.rulesMarkdown(HOMEBREW_EXAMPLE)
|
||||||
|
.characterTemplate(genericCharacterTemplate())
|
||||||
|
.npcTemplate(genericNpcTemplate())
|
||||||
.build()
|
.build()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Templates par defaut ---------------------------------------------
|
||||||
|
|
||||||
|
/** Template generique PJ — utilise pour Homebrew, backfill, et fallback. */
|
||||||
|
private static List<TemplateField> genericCharacterTemplate() {
|
||||||
|
return List.of(
|
||||||
|
TemplateField.text("Histoire"),
|
||||||
|
TemplateField.text("Personnalite"),
|
||||||
|
TemplateField.text("Apparence"),
|
||||||
|
TemplateField.image("Galerie", ImageLayout.GALLERY),
|
||||||
|
TemplateField.text("Notes")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Template generique PNJ — focus besoins MJ. */
|
||||||
|
private static List<TemplateField> genericNpcTemplate() {
|
||||||
|
return List.of(
|
||||||
|
TemplateField.text("Apparence"),
|
||||||
|
TemplateField.text("Motivation"),
|
||||||
|
TemplateField.text("Faction"),
|
||||||
|
TemplateField.text("Notes MJ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<TemplateField> nimbleCharacterTemplate() {
|
||||||
|
return List.of(
|
||||||
|
TemplateField.text("Classe"),
|
||||||
|
TemplateField.number("Blessures graves max"),
|
||||||
|
TemplateField.text("Capacites de classe"),
|
||||||
|
TemplateField.text("Equipement"),
|
||||||
|
TemplateField.text("Histoire"),
|
||||||
|
TemplateField.text("Objectifs personnels"),
|
||||||
|
TemplateField.image("Galerie", ImageLayout.GALLERY)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<TemplateField> dndCharacterTemplate() {
|
||||||
|
return List.of(
|
||||||
|
TemplateField.text("Classe"),
|
||||||
|
TemplateField.text("Race"),
|
||||||
|
TemplateField.text("Historique"),
|
||||||
|
TemplateField.text("Alignement"),
|
||||||
|
TemplateField.number("Niveau"),
|
||||||
|
TemplateField.number("PV max"),
|
||||||
|
TemplateField.number("CA"),
|
||||||
|
TemplateField.number("FOR"),
|
||||||
|
TemplateField.number("DEX"),
|
||||||
|
TemplateField.number("CON"),
|
||||||
|
TemplateField.number("INT"),
|
||||||
|
TemplateField.number("SAG"),
|
||||||
|
TemplateField.number("CHA"),
|
||||||
|
TemplateField.text("Competences"),
|
||||||
|
TemplateField.text("Equipement"),
|
||||||
|
TemplateField.text("Sorts"),
|
||||||
|
TemplateField.text("Histoire"),
|
||||||
|
TemplateField.image("Galerie", ImageLayout.GALLERY)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private static final String NIMBLE_RULES = """
|
private static final String NIMBLE_RULES = """
|
||||||
Système Nimble — résolution rapide, narration fluide, peu de tableaux. Agnostique (aucun univers imposé).
|
Système Nimble — résolution rapide, narration fluide, peu de tableaux. Agnostique (aucun univers imposé).
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ export const routes: Routes = [
|
|||||||
{ path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) },
|
{ path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) },
|
||||||
{ path: 'campaigns/:campaignId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
|
{ path: 'campaigns/:campaignId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
|
||||||
{ path: 'campaigns/:campaignId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
|
{ path: 'campaigns/:campaignId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
|
||||||
|
{ path: 'campaigns/:campaignId/characters/:characterId', loadComponent: () => import('./campaigns/character/character-view/character-view.component').then(m => m.CharacterViewComponent) },
|
||||||
{ path: 'campaigns/:campaignId/npcs/create', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
|
{ path: 'campaigns/:campaignId/npcs/create', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
|
||||||
{ path: 'campaigns/:campaignId/npcs/:npcId/edit', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
|
{ path: 'campaigns/:campaignId/npcs/:npcId/edit', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
|
||||||
|
{ path: 'campaigns/:campaignId/npcs/:npcId', loadComponent: () => import('./campaigns/npc/npc-view/npc-view.component').then(m => m.NpcViewComponent) },
|
||||||
{ path: 'campaigns/:campaignId/arcs/create', loadComponent: () => import('./campaigns/arc/arc-create/arc-create.component').then(m => m.ArcCreateComponent) },
|
{ path: 'campaigns/:campaignId/arcs/create', loadComponent: () => import('./campaigns/arc/arc-create/arc-create.component').then(m => m.ArcCreateComponent) },
|
||||||
{ path: 'campaigns/:campaignId/arcs/:arcId', loadComponent: () => import('./campaigns/arc/arc-view/arc-view.component').then(m => m.ArcViewComponent) },
|
{ path: 'campaigns/:campaignId/arcs/:arcId', loadComponent: () => import('./campaigns/arc/arc-view/arc-view.component').then(m => m.ArcViewComponent) },
|
||||||
{ path: 'campaigns/:campaignId/arcs/:arcId/edit', loadComponent: () => import('./campaigns/arc/arc-edit/arc-edit.component').then(m => m.ArcEditComponent) },
|
{ path: 'campaigns/:campaignId/arcs/:arcId/edit', loadComponent: () => import('./campaigns/arc/arc-edit/arc-edit.component').then(m => m.ArcEditComponent) },
|
||||||
|
|||||||
@@ -90,7 +90,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="characters-grid" *ngIf="characters.length > 0">
|
<div class="characters-grid" *ngIf="characters.length > 0">
|
||||||
<div class="character-card" *ngFor="let character of characters" (click)="editCharacter(character)">
|
<div class="character-card" *ngFor="let character of characters" (click)="viewCharacter(character)">
|
||||||
<lucide-icon [img]="User" [size]="20" class="character-icon"></lucide-icon>
|
<lucide-icon [img]="User" [size]="20" class="character-icon"></lucide-icon>
|
||||||
<div class="character-info">
|
<div class="character-info">
|
||||||
<span class="character-name">{{ character.name }}</span>
|
<span class="character-name">{{ character.name }}</span>
|
||||||
@@ -123,7 +123,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="characters-grid" *ngIf="npcs.length > 0">
|
<div class="characters-grid" *ngIf="npcs.length > 0">
|
||||||
<div class="character-card" *ngFor="let npc of npcs" (click)="editNpc(npc)">
|
<div class="character-card" *ngFor="let npc of npcs" (click)="viewNpc(npc)">
|
||||||
<lucide-icon [img]="Drama" [size]="20" class="character-icon character-icon--npc"></lucide-icon>
|
<lucide-icon [img]="Drama" [size]="20" class="character-icon character-icon--npc"></lucide-icon>
|
||||||
<div class="character-info">
|
<div class="character-info">
|
||||||
<span class="character-name">{{ npc.name }}</span>
|
<span class="character-name">{{ npc.name }}</span>
|
||||||
|
|||||||
@@ -194,6 +194,17 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
this.router.navigate(['/campaigns', this.campaign.id, 'characters', character.id, 'edit']);
|
this.router.navigate(['/campaigns', this.campaign.id, 'characters', character.id, 'edit']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ouvre la vue lecture seule (style WorldAnvil) — clic sur la carte. */
|
||||||
|
viewCharacter(character: Character): void {
|
||||||
|
if (!this.campaign || !character.id) return;
|
||||||
|
this.router.navigate(['/campaigns', this.campaign.id, 'characters', character.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
viewNpc(npc: Npc): void {
|
||||||
|
if (!this.campaign || !npc.id) return;
|
||||||
|
this.router.navigate(['/campaigns', this.campaign.id, 'npcs', npc.id]);
|
||||||
|
}
|
||||||
|
|
||||||
createArc(): void {
|
createArc(): void {
|
||||||
if (!this.campaign) return;
|
if (!this.campaign) return;
|
||||||
this.router.navigate(['/campaigns', this.campaign.id, 'arcs', 'create']);
|
this.router.navigate(['/campaigns', this.campaign.id, 'arcs', 'create']);
|
||||||
|
|||||||
@@ -35,29 +35,26 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field-row">
|
<div class="field-row image-row">
|
||||||
<div class="field">
|
<div class="field portrait-field">
|
||||||
<label>Portrait (ID image)</label>
|
<label>Portrait</label>
|
||||||
<input
|
<app-single-image-picker
|
||||||
type="text"
|
[imageId]="portraitImageId"
|
||||||
[(ngModel)]="portraitImageId"
|
aspectRatio="1 / 1"
|
||||||
name="portraitImageId"
|
hint="Carre conseille (400×400)."
|
||||||
placeholder="ID de l'image portrait"
|
(imageIdChange)="portraitImageId = $event">
|
||||||
/>
|
</app-single-image-picker>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field header-field">
|
||||||
<label>Bandeau / Header (ID image)</label>
|
<label>Bandeau / Header</label>
|
||||||
<input
|
<app-single-image-picker
|
||||||
type="text"
|
[imageId]="headerImageId"
|
||||||
[(ngModel)]="headerImageId"
|
aspectRatio="3 / 1"
|
||||||
name="headerImageId"
|
hint="Format paysage conseille (1200×400)."
|
||||||
placeholder="ID de l'image bandeau"
|
(imageIdChange)="headerImageId = $event">
|
||||||
/>
|
</app-single-image-picker>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">
|
|
||||||
Les portraits et bandeaux acceptent un ID d'image (MVP). Picker visuel a venir.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="template-fields">
|
<div class="template-fields">
|
||||||
<app-dynamic-fields-form
|
<app-dynamic-fields-form
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lu
|
|||||||
import { CharacterService } from '../../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { CampaignService } from '../../../services/campaign.service';
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
import { GameSystemService } from '../../../services/game-system.service';
|
import { GameSystemService } from '../../../services/game-system.service';
|
||||||
import { TemplateField } from '../../../services/template-field.model';
|
import { TemplateField } from '../../../services/template.model';
|
||||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
|
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
|
||||||
|
import { SingleImagePickerComponent } from '../../../shared/single-image-picker/single-image-picker.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Editeur plein ecran d'une fiche de personnage (PJ).
|
* Editeur plein ecran d'une fiche de personnage (PJ).
|
||||||
@@ -24,7 +25,7 @@ import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-character-edit',
|
selector: 'app-character-edit',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent],
|
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent],
|
||||||
templateUrl: './character-edit.component.html',
|
templateUrl: './character-edit.component.html',
|
||||||
styleUrls: ['./character-edit.component.scss']
|
styleUrls: ['./character-edit.component.scss']
|
||||||
})
|
})
|
||||||
@@ -48,8 +49,8 @@ export class CharacterEditComponent implements OnInit {
|
|||||||
characterId: string | null = null;
|
characterId: string | null = null;
|
||||||
|
|
||||||
name = '';
|
name = '';
|
||||||
portraitImageId = '';
|
portraitImageId: string | null = null;
|
||||||
headerImageId = '';
|
headerImageId: string | null = null;
|
||||||
values: Record<string, string> = {};
|
values: Record<string, string> = {};
|
||||||
imageValues: Record<string, string[]> = {};
|
imageValues: Record<string, string[]> = {};
|
||||||
templateFields: TemplateField[] = [];
|
templateFields: TemplateField[] = [];
|
||||||
@@ -76,8 +77,8 @@ export class CharacterEditComponent implements OnInit {
|
|||||||
this.service.getById(this.characterId).subscribe({
|
this.service.getById(this.characterId).subscribe({
|
||||||
next: (c) => {
|
next: (c) => {
|
||||||
this.name = c.name;
|
this.name = c.name;
|
||||||
this.portraitImageId = c.portraitImageId ?? '';
|
this.portraitImageId = c.portraitImageId ?? null;
|
||||||
this.headerImageId = c.headerImageId ?? '';
|
this.headerImageId = c.headerImageId ?? null;
|
||||||
this.values = c.values ?? {};
|
this.values = c.values ?? {};
|
||||||
this.imageValues = c.imageValues ?? {};
|
this.imageValues = c.imageValues ?? {};
|
||||||
this.order = c.order ?? 0;
|
this.order = c.order ?? 0;
|
||||||
@@ -107,8 +108,8 @@ export class CharacterEditComponent implements OnInit {
|
|||||||
if (!this.name.trim() || !this.campaignId) return;
|
if (!this.name.trim() || !this.campaignId) return;
|
||||||
const payload = {
|
const payload = {
|
||||||
name: this.name.trim(),
|
name: this.name.trim(),
|
||||||
portraitImageId: this.portraitImageId.trim() || null,
|
portraitImageId: this.portraitImageId,
|
||||||
headerImageId: this.headerImageId.trim() || null,
|
headerImageId: this.headerImageId,
|
||||||
values: this.values,
|
values: this.values,
|
||||||
imageValues: this.imageValues,
|
imageValues: this.imageValues,
|
||||||
campaignId: this.campaignId
|
campaignId: this.campaignId
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
<div class="field-row">
|
<div class="field-row image-row">
|
||||||
<div class="field">
|
<div class="field portrait-field">
|
||||||
<label>Portrait (ID image)</label>
|
<label>Portrait</label>
|
||||||
<input type="text" [(ngModel)]="portraitImageId" name="portraitImageId" placeholder="ID de l'image portrait" />
|
<app-single-image-picker
|
||||||
|
[imageId]="portraitImageId"
|
||||||
|
aspectRatio="1 / 1"
|
||||||
|
hint="Carre conseille (400×400)."
|
||||||
|
(imageIdChange)="portraitImageId = $event">
|
||||||
|
</app-single-image-picker>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field header-field">
|
||||||
<label>Bandeau / Header (ID image)</label>
|
<label>Bandeau / Header</label>
|
||||||
<input type="text" [(ngModel)]="headerImageId" name="headerImageId" placeholder="ID de l'image bandeau" />
|
<app-single-image-picker
|
||||||
|
[imageId]="headerImageId"
|
||||||
|
aspectRatio="3 / 1"
|
||||||
|
hint="Format paysage conseille (1200×400)."
|
||||||
|
(imageIdChange)="headerImageId = $event">
|
||||||
|
</app-single-image-picker>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">
|
|
||||||
Les portraits et bandeaux acceptent un ID d'image (MVP). Picker visuel a venir.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="template-fields">
|
<div class="template-fields">
|
||||||
<app-dynamic-fields-form
|
<app-dynamic-fields-form
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import { LucideAngularModule, Save, ArrowLeft, Drama, Trash2, Sparkles } from 'l
|
|||||||
import { NpcService } from '../../../services/npc.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { CampaignService } from '../../../services/campaign.service';
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
import { GameSystemService } from '../../../services/game-system.service';
|
import { GameSystemService } from '../../../services/game-system.service';
|
||||||
import { TemplateField } from '../../../services/template-field.model';
|
import { TemplateField } from '../../../services/template.model';
|
||||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
|
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
|
||||||
|
import { SingleImagePickerComponent } from '../../../shared/single-image-picker/single-image-picker.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Editeur plein ecran d'une fiche de PNJ.
|
* Editeur plein ecran d'une fiche de PNJ.
|
||||||
@@ -19,7 +20,7 @@ import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-npc-edit',
|
selector: 'app-npc-edit',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent],
|
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent],
|
||||||
templateUrl: './npc-edit.component.html',
|
templateUrl: './npc-edit.component.html',
|
||||||
styleUrls: ['./npc-edit.component.scss']
|
styleUrls: ['./npc-edit.component.scss']
|
||||||
})
|
})
|
||||||
@@ -43,8 +44,8 @@ export class NpcEditComponent implements OnInit {
|
|||||||
npcId: string | null = null;
|
npcId: string | null = null;
|
||||||
|
|
||||||
name = '';
|
name = '';
|
||||||
portraitImageId = '';
|
portraitImageId: string | null = null;
|
||||||
headerImageId = '';
|
headerImageId: string | null = null;
|
||||||
values: Record<string, string> = {};
|
values: Record<string, string> = {};
|
||||||
imageValues: Record<string, string[]> = {};
|
imageValues: Record<string, string[]> = {};
|
||||||
templateFields: TemplateField[] = [];
|
templateFields: TemplateField[] = [];
|
||||||
@@ -71,8 +72,8 @@ export class NpcEditComponent implements OnInit {
|
|||||||
this.service.getById(this.npcId).subscribe({
|
this.service.getById(this.npcId).subscribe({
|
||||||
next: (n) => {
|
next: (n) => {
|
||||||
this.name = n.name;
|
this.name = n.name;
|
||||||
this.portraitImageId = n.portraitImageId ?? '';
|
this.portraitImageId = n.portraitImageId ?? null;
|
||||||
this.headerImageId = n.headerImageId ?? '';
|
this.headerImageId = n.headerImageId ?? null;
|
||||||
this.values = n.values ?? {};
|
this.values = n.values ?? {};
|
||||||
this.imageValues = n.imageValues ?? {};
|
this.imageValues = n.imageValues ?? {};
|
||||||
this.order = n.order ?? 0;
|
this.order = n.order ?? 0;
|
||||||
@@ -102,8 +103,8 @@ export class NpcEditComponent implements OnInit {
|
|||||||
if (!this.name.trim() || !this.campaignId) return;
|
if (!this.name.trim() || !this.campaignId) return;
|
||||||
const payload = {
|
const payload = {
|
||||||
name: this.name.trim(),
|
name: this.name.trim(),
|
||||||
portraitImageId: this.portraitImageId.trim() || null,
|
portraitImageId: this.portraitImageId,
|
||||||
headerImageId: this.headerImageId.trim() || null,
|
headerImageId: this.headerImageId,
|
||||||
values: this.values,
|
values: this.values,
|
||||||
imageValues: this.imageValues,
|
imageValues: this.imageValues,
|
||||||
campaignId: this.campaignId
|
campaignId: this.campaignId
|
||||||
|
|||||||
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 { FormsModule } from '@angular/forms';
|
||||||
import { LucideAngularModule, Save, ArrowLeft, Dices, Plus, Trash2, ChevronDown, ChevronRight } from 'lucide-angular';
|
import { LucideAngularModule, Save, ArrowLeft, Dices, Plus, Trash2, ChevronDown, ChevronRight } from 'lucide-angular';
|
||||||
import { GameSystemService } from '../../services/game-system.service';
|
import { GameSystemService } from '../../services/game-system.service';
|
||||||
import { TemplateField } from '../../services/template-field.model';
|
import { TemplateField } from '../../services/template.model';
|
||||||
import { TemplateFieldsEditorComponent } from '../../shared/template-fields-editor/template-fields-editor.component';
|
import { TemplateFieldsEditorComponent } from '../../shared/template-fields-editor/template-fields-editor.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TemplateField } from './template-field.model';
|
import { TemplateField } from './template.model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface TypeScript pour GameSystemDTO (jumeau du DTO Java).
|
* Interface TypeScript pour GameSystemDTO (jumeau du DTO Java).
|
||||||
|
|||||||
@@ -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).
|
// Interfaces TypeScript pour TemplateDTO (Backend Java).
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type d'un champ de Template. Miroir de com.loremind.domain.lorecontext.FieldType.
|
* Type d'un champ de Template. Miroir de com.loremind.domain.shared.template.FieldType.
|
||||||
* - 'TEXT' : champ textuel libre (rendu en textarea)
|
* - 'TEXT' : champ textuel libre (rendu en textarea)
|
||||||
* - 'IMAGE' : galerie d'images (rendu en app-image-gallery)
|
* - 'IMAGE' : galerie d'images (rendu en app-image-gallery)
|
||||||
|
* - 'NUMBER' : valeur numerique (rendu en input number)
|
||||||
*/
|
*/
|
||||||
export type FieldType = 'TEXT' | 'IMAGE';
|
export type FieldType = 'TEXT' | 'IMAGE' | 'NUMBER';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Variante de rendu pour un champ IMAGE. Miroir de
|
* Variante de rendu pour un champ IMAGE. Miroir de
|
||||||
|
|||||||
@@ -21,16 +21,13 @@
|
|||||||
placeholder="0"
|
placeholder="0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div *ngSwitchCase="'IMAGE'" class="image-mvp">
|
<app-image-gallery
|
||||||
<input
|
*ngSwitchCase="'IMAGE'"
|
||||||
type="text"
|
[editable]="true"
|
||||||
[ngModel]="imageCsvCache[f.name] || ''"
|
[layout]="f.layout || 'GALLERY'"
|
||||||
(ngModelChange)="onImageCsvChange(f, $event)"
|
[imageIds]="imagesFor(f)"
|
||||||
[name]="'img-' + f.name"
|
(imageIdsChange)="onImageIdsChange(f, $event)">
|
||||||
placeholder="IDs d'images separes par des virgules (MVP)"
|
</app-image-gallery>
|
||||||
/>
|
|
||||||
<small class="hint">Layout : {{ f.layout || 'GALLERY' }}. Picker dedie a venir.</small>
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Component, EventEmitter, Input, Output, OnChanges, SimpleChanges } from '@angular/core';
|
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { TemplateField } from '../../services/template-field.model';
|
import { TemplateField } from '../../services/template.model';
|
||||||
|
import { ImageGalleryComponent } from '../image-gallery/image-gallery.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formulaire dynamique pilote par une liste de TemplateField.
|
* Formulaire dynamique pilote par une liste de TemplateField.
|
||||||
@@ -11,19 +12,17 @@ import { TemplateField } from '../../services/template-field.model';
|
|||||||
* - values : Record<champName, string> pour les types TEXT et NUMBER
|
* - values : Record<champName, string> pour les types TEXT et NUMBER
|
||||||
* - imageValues : Record<champName, string[]> pour le type IMAGE
|
* - imageValues : Record<champName, string[]> pour le type IMAGE
|
||||||
*
|
*
|
||||||
* Emet `valuesChange` et `imageValuesChange` a chaque modification.
|
* Pour les champs IMAGE, delegue au composant <app-image-gallery editable>
|
||||||
*
|
* qui gere l'upload, la suppression et le respect du layout.
|
||||||
* Pour les champs IMAGE le MVP affiche une simple textarea CSV d'IDs (le picker
|
|
||||||
* d'images dedie sera branche plus tard, hors scope de la refonte template).
|
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-dynamic-fields-form',
|
selector: 'app-dynamic-fields-form',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule],
|
imports: [CommonModule, FormsModule, ImageGalleryComponent],
|
||||||
templateUrl: './dynamic-fields-form.component.html',
|
templateUrl: './dynamic-fields-form.component.html',
|
||||||
styleUrls: ['./dynamic-fields-form.component.scss']
|
styleUrls: ['./dynamic-fields-form.component.scss']
|
||||||
})
|
})
|
||||||
export class DynamicFieldsFormComponent implements OnChanges {
|
export class DynamicFieldsFormComponent {
|
||||||
@Input() fields: TemplateField[] = [];
|
@Input() fields: TemplateField[] = [];
|
||||||
@Input() values: Record<string, string> = {};
|
@Input() values: Record<string, string> = {};
|
||||||
@Input() imageValues: Record<string, string[]> = {};
|
@Input() imageValues: Record<string, string[]> = {};
|
||||||
@@ -31,32 +30,19 @@ export class DynamicFieldsFormComponent implements OnChanges {
|
|||||||
@Output() valuesChange = new EventEmitter<Record<string, string>>();
|
@Output() valuesChange = new EventEmitter<Record<string, string>>();
|
||||||
@Output() imageValuesChange = new EventEmitter<Record<string, string[]>>();
|
@Output() imageValuesChange = new EventEmitter<Record<string, string[]>>();
|
||||||
|
|
||||||
/** Cache de strings CSV pour edition d'imageValues sans serialisation continue. */
|
|
||||||
imageCsvCache: Record<string, string> = {};
|
|
||||||
|
|
||||||
ngOnChanges(changes: SimpleChanges): void {
|
|
||||||
if (changes['imageValues'] || changes['fields']) {
|
|
||||||
this.imageCsvCache = {};
|
|
||||||
for (const f of this.fields) {
|
|
||||||
if (f.type === 'IMAGE') {
|
|
||||||
const list = this.imageValues[f.name] ?? [];
|
|
||||||
this.imageCsvCache[f.name] = list.join(', ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onTextChange(field: TemplateField, value: string): void {
|
onTextChange(field: TemplateField, value: string): void {
|
||||||
this.values = { ...this.values, [field.name]: value };
|
this.values = { ...this.values, [field.name]: value };
|
||||||
this.valuesChange.emit(this.values);
|
this.valuesChange.emit(this.values);
|
||||||
}
|
}
|
||||||
|
|
||||||
onImageCsvChange(field: TemplateField, csv: string): void {
|
onImageIdsChange(field: TemplateField, ids: string[]): void {
|
||||||
this.imageCsvCache[field.name] = csv;
|
|
||||||
const ids = csv.split(',').map(s => s.trim()).filter(s => s.length > 0);
|
|
||||||
this.imageValues = { ...this.imageValues, [field.name]: ids };
|
this.imageValues = { ...this.imageValues, [field.name]: ids };
|
||||||
this.imageValuesChange.emit(this.imageValues);
|
this.imageValuesChange.emit(this.imageValues);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
imagesFor(field: TemplateField): string[] {
|
||||||
|
return this.imageValues[field.name] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
trackByName = (_: number, f: TemplateField) => f.name;
|
trackByName = (_: number, f: TemplateField) => f.name;
|
||||||
}
|
}
|
||||||
|
|||||||
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 { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { LucideAngularModule, Plus, Trash2, ArrowUp, ArrowDown, Type, Image as ImageIcon, Hash } from 'lucide-angular';
|
import { LucideAngularModule, Plus, Trash2, ArrowUp, ArrowDown, Type, Image as ImageIcon, Hash } from 'lucide-angular';
|
||||||
import { TemplateField, FieldType, ImageLayout } from '../../services/template-field.model';
|
import { TemplateField, FieldType, ImageLayout } from '../../services/template.model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Editeur reutilisable d'une liste de TemplateField.
|
* Editeur reutilisable d'une liste de TemplateField.
|
||||||
|
|||||||
Reference in New Issue
Block a user