Changement sur le Readme

Ajout d'une partie spécifique pour des PNJ dans la partie campagne
This commit is contained in:
2026-04-27 15:48:04 +02:00
parent aaebeaa547
commit 389392fd1d
80 changed files with 1771 additions and 719 deletions

View File

@@ -15,19 +15,21 @@ export const routes: Routes = [
{ path: 'lore/:loreId/pages/:pageId', loadComponent: () => import('./lore/page-view/page-view.component').then(m => m.PageViewComponent) },
{ path: 'lore/:loreId/pages/:pageId/edit', loadComponent: () => import('./lore/page-edit/page-edit.component').then(m => m.PageEditComponent) },
{ path: 'campaigns', loadComponent: () => import('./campaigns/campaigns.component').then(m => m.CampaignsComponent) },
{ path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) },
{ path: 'campaigns/:campaignId/characters/create', loadComponent: () => import('./campaigns/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
{ path: 'campaigns/:campaignId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
{ path: 'campaigns/:campaignId/arcs/create', loadComponent: () => import('./campaigns/arc-create/arc-create.component').then(m => m.ArcCreateComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId', loadComponent: () => import('./campaigns/arc-view/arc-view.component').then(m => m.ArcViewComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/edit', loadComponent: () => import('./campaigns/arc-edit/arc-edit.component').then(m => m.ArcEditComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/create', loadComponent: () => import('./campaigns/chapter-create/chapter-create.component').then(m => m.ChapterCreateComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId', loadComponent: () => import('./campaigns/chapter-view/chapter-view.component').then(m => m.ChapterViewComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/graph', loadComponent: () => import('./campaigns/chapter-graph/chapter-graph.component').then(m => m.ChapterGraphComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/edit', loadComponent: () => import('./campaigns/chapter-edit/chapter-edit.component').then(m => m.ChapterEditComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/create', loadComponent: () => import('./campaigns/scene-create/scene-create.component').then(m => m.SceneCreateComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId', loadComponent: () => import('./campaigns/scene-view/scene-view.component').then(m => m.SceneViewComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId/edit', loadComponent: () => import('./campaigns/scene-edit/scene-edit.component').then(m => m.SceneEditComponent) },
{ 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/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/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) },
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/create', loadComponent: () => import('./campaigns/chapter/chapter-create/chapter-create.component').then(m => m.ChapterCreateComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId', loadComponent: () => import('./campaigns/chapter/chapter-view/chapter-view.component').then(m => m.ChapterViewComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/graph', loadComponent: () => import('./campaigns/chapter/chapter-graph/chapter-graph.component').then(m => m.ChapterGraphComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/edit', loadComponent: () => import('./campaigns/chapter/chapter-edit/chapter-edit.component').then(m => m.ChapterEditComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/create', loadComponent: () => import('./campaigns/scene/scene-create/scene-create.component').then(m => m.SceneCreateComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId', loadComponent: () => import('./campaigns/scene/scene-view/scene-view.component').then(m => m.SceneViewComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId/edit', loadComponent: () => import('./campaigns/scene/scene-edit/scene-edit.component').then(m => m.SceneEditComponent) },
{ path: 'game-systems', loadComponent: () => import('./game-systems/game-systems.component').then(m => m.GameSystemsComponent) },
{ path: 'game-systems/create', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) },
{ path: 'game-systems/:id/edit', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) },

View File

@@ -4,13 +4,14 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angula
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule, BookOpen } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { Campaign } from '../../services/campaign.model';
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { LayoutService, GlobalItem } from '../../../services/layout.service';
import { Campaign } from '../../../services/campaign.model';
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
/**
* Écran de création d'un nouvel Arc narratif (contexte Campagne).
@@ -39,6 +40,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private layoutService: LayoutService
) {
this.form = this.fb.group({
@@ -56,7 +58,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
}).subscribe(({ campaign, allCampaigns, treeData }) => {
this.existingArcCount = treeData.arcs.length;

View File

@@ -5,19 +5,20 @@ import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { PageService } from '../../services/page.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { Campaign, Arc } from '../../services/campaign.model';
import { Page } from '../../services/page.model';
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { PageService } from '../../../services/page.service';
import { LayoutService, GlobalItem } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Campaign, Arc } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model';
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
/**
* Écran de détail/modification d'un Arc.
@@ -74,6 +75,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
@@ -111,7 +113,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
arc: this.campaignService.getArcById(this.arcId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
}).pipe(
switchMap(data => {
const lid = data.campaign.loreId ?? null;

View File

@@ -4,16 +4,17 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
import { resolveCampaignIcon } from '../campaign-icons';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { PageService } from '../../services/page.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { Campaign, Arc } from '../../services/campaign.model';
import { Page } from '../../services/page.model';
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
import { resolveCampaignIcon } from '../../campaign-icons';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { PageService } from '../../../services/page.service';
import { LayoutService, GlobalItem } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Campaign, Arc } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model';
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
/**
* Écran de consultation d'un Arc narratif (lecture seule).
@@ -46,6 +47,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
@@ -68,7 +70,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
arc: this.campaignService.getArcById(this.arcId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
}).pipe(
switchMap(data => {
const lid = data.campaign.loreId ?? null;

View File

@@ -2,9 +2,11 @@ import { Observable, forkJoin, of } from 'rxjs';
import { switchMap, map } from 'rxjs/operators';
import { CampaignService } from '../services/campaign.service';
import { CharacterService } from '../services/character.service';
import { NpcService } from '../services/npc.service';
import { TreeItem } from '../services/layout.service';
import { Arc, Chapter, Scene } from '../services/campaign.model';
import { Character } from '../services/character.model';
import { Npc } from '../services/npc.model';
/**
* Helper — charge l'arborescence complète d'une campagne (arcs -> chapitres -> scènes)
@@ -19,20 +21,23 @@ export interface CampaignTreeData {
chaptersByArc: Record<string, Chapter[]>;
scenesByChapter: Record<string, Scene[]>;
characters: Character[];
npcs: Npc[];
}
export function loadCampaignTreeData(
service: CampaignService,
campaignId: string,
characterService: CharacterService
characterService: CharacterService,
npcService: NpcService
): Observable<CampaignTreeData> {
return forkJoin({
arcs: service.getArcs(campaignId),
characters: characterService.getByCampaign(campaignId)
characters: characterService.getByCampaign(campaignId),
npcs: npcService.getByCampaign(campaignId)
}).pipe(
switchMap(({ arcs, characters }) => {
switchMap(({ arcs, characters, npcs }) => {
if (arcs.length === 0) {
return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters });
return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters, npcs });
}
const chapterCalls = arcs.map(a =>
service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters })))
@@ -47,7 +52,7 @@ export function loadCampaignTreeData(
});
if (allChapters.length === 0) {
return of({ arcs, chaptersByArc, scenesByChapter: {}, characters });
return of({ arcs, chaptersByArc, scenesByChapter: {}, characters, npcs });
}
const sceneCalls = allChapters.map(c =>
service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes })))
@@ -56,7 +61,7 @@ export function loadCampaignTreeData(
map(sceneResults => {
const scenesByChapter: Record<string, Scene[]> = {};
sceneResults.forEach(r => { scenesByChapter[r.chapterId] = r.scenes; });
return { arcs, chaptersByArc, scenesByChapter, characters };
return { arcs, chaptersByArc, scenesByChapter, characters, npcs };
})
);
})
@@ -83,13 +88,13 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
const charactersNode: TreeItem = {
id: 'characters-root',
label: 'Personnages',
label: 'PJ',
iconKey: 'users',
children: characterItems,
meta: characterItems.length ? String(characterItems.length) : undefined,
sectionHeaderBefore: 'Personnages',
// Note : si pas d'arcs, le filet au-dessus de "Personnages" est masqué par CSS
// (:first-child), ce qui est voulu — on ne veut pas de ligne seule en haut.
// Note : le section header "Personnages" est porté par le premier nœud (PJ).
// Le filet au-dessus est masqué par CSS si c'est le tout premier item de la sidebar.
createActions: [{
id: 'new-character',
label: 'Nouveau PJ',
@@ -98,6 +103,28 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
}]
};
const sortedNpcs = [...data.npcs].sort(byName);
const npcItems: TreeItem[] = sortedNpcs.map(n => ({
id: `npc-${n.id}`,
label: n.name,
route: `/campaigns/${campaignId}/npcs/${n.id}/edit`
}));
const npcsNode: TreeItem = {
id: 'npcs-root',
label: 'PNJ',
iconKey: 'c-drama',
children: npcItems,
meta: npcItems.length ? String(npcItems.length) : undefined,
// Pas de sectionHeaderBefore : on reste sous le header "Personnages" du nœud PJ.
createActions: [{
id: 'new-npc',
label: 'Nouveau PNJ',
route: `/campaigns/${campaignId}/npcs/create`,
actionIcon: 'plus'
}]
};
const sortedArcs = [...data.arcs].sort(byName);
const arcNodes: TreeItem[] = sortedArcs.map((arc, idx) => {
@@ -143,5 +170,5 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
};
});
return [...arcNodes, charactersNode];
return [...arcNodes, charactersNode, npcsNode];
}

View File

@@ -2,10 +2,10 @@ import { Component, EventEmitter, OnInit, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { LucideAngularModule, BookCopy, X } from 'lucide-angular';
import { LoreService } from '../../services/lore.service';
import { Lore } from '../../services/lore.model';
import { GameSystemService } from '../../services/game-system.service';
import { GameSystem } from '../../services/game-system.model';
import { LoreService } from '../../../services/lore.service';
import { Lore } from '../../../services/lore.model';
import { GameSystemService } from '../../../services/game-system.service';
import { GameSystem } from '../../../services/game-system.model';
/**
* Payload émis vers le parent à la création d'une campagne.

View File

@@ -70,32 +70,75 @@
</div>
</div>
<section class="detail-section characters-section" *ngIf="!editing">
<section class="detail-section personas-section" *ngIf="!editing">
<div class="section-header">
<h2>Personnages joueurs</h2>
<button class="btn-add" (click)="createCharacter()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouveau PJ
</button>
<h2>Personnages</h2>
</div>
<div class="characters-grid" *ngIf="characters.length > 0">
<div class="character-card" *ngFor="let character of characters" (click)="editCharacter(character)">
<lucide-icon [img]="User" [size]="20" class="character-icon"></lucide-icon>
<div class="character-info">
<span class="character-name">{{ character.name }}</span>
<span class="character-snippet">{{ characterSnippet(character) }}</span>
<!-- Sous-section : Personnages joueurs (PJ) -->
<div class="persona-subsection">
<div class="subsection-header">
<h3>
<lucide-icon [img]="User" [size]="16"></lucide-icon>
Personnages joueurs
<span class="count-badge" *ngIf="characters.length > 0">{{ characters.length }}</span>
</h3>
<button class="btn-add" (click)="createCharacter()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouveau PJ
</button>
</div>
<div class="characters-grid" *ngIf="characters.length > 0">
<div class="character-card" *ngFor="let character of characters" (click)="editCharacter(character)">
<lucide-icon [img]="User" [size]="20" class="character-icon"></lucide-icon>
<div class="character-info">
<span class="character-name">{{ character.name }}</span>
<span class="character-snippet">{{ personaSnippet(character) }}</span>
</div>
</div>
</div>
<div class="empty-state empty-state--compact" *ngIf="characters.length === 0">
<p>Aucun personnage joueur pour le moment.</p>
<button class="btn-add-first" (click)="createCharacter()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Créer votre premier PJ
</button>
</div>
</div>
<div class="empty-state" *ngIf="characters.length === 0">
<lucide-icon [img]="User" [size]="40" class="empty-icon"></lucide-icon>
<p>Aucun personnage joueur pour le moment.</p>
<button class="btn-add-first" (click)="createCharacter()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Créer votre premier PJ
</button>
<!-- Sous-section : Personnages non-joueurs (PNJ) -->
<div class="persona-subsection">
<div class="subsection-header">
<h3>
<lucide-icon [img]="Drama" [size]="16"></lucide-icon>
Personnages non-joueurs
<span class="count-badge" *ngIf="npcs.length > 0">{{ npcs.length }}</span>
</h3>
<button class="btn-add" (click)="createNpc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouveau PNJ
</button>
</div>
<div class="characters-grid" *ngIf="npcs.length > 0">
<div class="character-card" *ngFor="let npc of npcs" (click)="editNpc(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>
<span class="character-snippet">{{ personaSnippet(npc) }}</span>
</div>
</div>
</div>
<div class="empty-state empty-state--compact" *ngIf="npcs.length === 0">
<p>Aucun PNJ pour le moment.</p>
<button class="btn-add-first" (click)="createNpc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Créer votre premier PNJ
</button>
</div>
</div>
</section>

View File

@@ -197,6 +197,54 @@
}
// Encart "Personnages" qui regroupe les sous-sections PJ et PNJ.
.personas-section {
.persona-subsection + .persona-subsection {
margin-top: 1.75rem;
padding-top: 1.5rem;
border-top: 1px solid #1f2937;
}
}
.subsection-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
h3 {
display: flex;
align-items: center;
gap: 0.5rem;
color: #d1d5db;
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
margin: 0;
lucide-icon { color: #a78bfa; }
}
.count-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.4rem;
height: 1.4rem;
padding: 0 0.45rem;
background: #1f2937;
color: #a78bfa;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0;
text-transform: none;
margin-left: 0.25rem;
}
}
.characters-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
@@ -243,8 +291,23 @@
.empty-icon { color: #374151; }
p { font-size: 0.95rem; }
// Variante condensée pour les sous-sections PJ/PNJ pas besoin du
// padding vertical massif quand l'encart parent en porte déjà.
&.empty-state--compact {
padding: 1.5rem 1rem;
gap: 0.75rem;
p {
font-size: 0.85rem;
margin: 0;
}
}
}
// Variante d'icône pour les cartes PNJ (rouge-violet pour différencier des PJ).
.character-icon--npc { color: #c084fc !important; }
.btn-add-first {
display: flex;
align-items: center;

View File

@@ -2,21 +2,23 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, User, Dices } from 'lucide-angular';
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, User, Dices, Drama } from 'lucide-angular';
import { Router, RouterLink } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { catchError, switchMap, filter, map } from 'rxjs/operators';
import { CampaignService } from '../../services/campaign.service';
import { LoreService } from '../../services/lore.service';
import { GameSystemService } from '../../services/game-system.service';
import { GameSystem } from '../../services/game-system.model';
import { CharacterService } from '../../services/character.service';
import { Character } from '../../services/character.model';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { Campaign, Arc } from '../../services/campaign.model';
import { Lore } from '../../services/lore.model';
import { loadCampaignTreeData, buildCampaignTree, CampaignTreeData } from '../campaign-tree.helper';
import { CampaignService } from '../../../services/campaign.service';
import { LoreService } from '../../../services/lore.service';
import { GameSystemService } from '../../../services/game-system.service';
import { GameSystem } from '../../../services/game-system.model';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { Character } from '../../../services/character.model';
import { Npc } from '../../../services/npc.model';
import { LayoutService, GlobalItem } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Campaign, Arc } from '../../../services/campaign.model';
import { Lore } from '../../../services/lore.model';
import { loadCampaignTreeData, buildCampaignTree, CampaignTreeData } from '../../campaign-tree.helper';
@Component({
selector: 'app-campaign-detail',
@@ -33,6 +35,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
readonly Trash2 = Trash2;
readonly User = User;
readonly Dices = Dices;
readonly Drama = Drama;
campaign: Campaign | null = null;
arcs: Arc[] = [];
@@ -48,6 +51,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
linkedGameSystem: GameSystem | null = null;
/** Fiches de personnages (PJ) de la campagne. */
characters: Character[] = [];
/** Fiches de personnages non-joueurs (PNJ) de la campagne. */
npcs: Npc[] = [];
/** Mode édition inline. */
editing = false;
@@ -63,6 +68,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
private loreService: LoreService,
private gameSystemService: GameSystemService,
private characterService: CharacterService,
private npcService: NpcService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
) {}
@@ -77,8 +83,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
switchMap(id => forkJoin({
campaign: this.campaignService.getCampaignById(id),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [] } as CampaignTreeData))
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [] } as CampaignTreeData))
)
}))
).subscribe(({ campaign, allCampaigns, treeData }) => {
@@ -87,6 +93,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
this.loadLinkedLore(campaign);
this.loadLinkedGameSystem(campaign);
this.loadCharacters(campaign.id!);
this.loadNpcs(campaign.id!);
this.arcs = treeData.arcs;
this.chapterCountByArc = this.computeChapterCounts(treeData);
this.showLayout(allCampaigns, treeData);
@@ -111,8 +118,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
forkJoin({
campaign: this.campaignService.getCampaignById(id),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [] } as CampaignTreeData))
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [] } as CampaignTreeData))
)
}).subscribe(({ campaign, allCampaigns, treeData }) => {
this.campaign = campaign;
@@ -120,6 +127,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
this.loadLinkedLore(campaign);
this.loadLinkedGameSystem(campaign);
this.loadCharacters(campaign.id!);
this.loadNpcs(campaign.id!);
this.arcs = treeData.arcs;
this.chapterCountByArc = this.computeChapterCounts(treeData);
this.showLayout(allCampaigns, treeData);
@@ -159,11 +167,28 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
).subscribe(list => this.characters = list);
}
/** Symétrique pour les PNJ. */
private loadNpcs(campaignId: string): void {
this.npcService.getByCampaign(campaignId).pipe(
catchError(() => of([] as Npc[]))
).subscribe(list => this.npcs = list);
}
createCharacter(): void {
if (!this.campaign) return;
this.router.navigate(['/campaigns', this.campaign.id, 'characters', 'create']);
}
createNpc(): void {
if (!this.campaign) return;
this.router.navigate(['/campaigns', this.campaign.id, 'npcs', 'create']);
}
editNpc(npc: Npc): void {
if (!this.campaign || !npc.id) return;
this.router.navigate(['/campaigns', this.campaign.id, 'npcs', npc.id, 'edit']);
}
editCharacter(character: Character): void {
if (!this.campaign || !character.id) return;
this.router.navigate(['/campaigns', this.campaign.id, 'characters', character.id, 'edit']);
@@ -179,10 +204,13 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
this.router.navigate(['/campaigns', this.campaign.id, 'arcs', arc.id]);
}
/** Extrait une ligne de résumé depuis le markdown (1re ligne non-vide, non-titre). */
characterSnippet(c: Character): string {
if (!c.markdownContent) return '(Fiche vide)';
const firstMeaningful = c.markdownContent
/**
* Extrait une ligne de résumé depuis le markdown (1re ligne non-vide, non-titre).
* Générique : utilisé pour les fiches PJ comme PNJ (mêmes besoins d'aperçu carte).
*/
personaSnippet(p: { markdownContent?: string | null }): string {
if (!p.markdownContent) return '(Fiche vide)';
const firstMeaningful = p.markdownContent
.split('\n')
.map(l => l.trim())
.find(l => l && !l.startsWith('#'));
@@ -192,6 +220,11 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
: firstMeaningful;
}
/** Alias gardé pour compatibilité avec les anciens templates. */
characterSnippet(c: Character): string {
return this.personaSnippet(c);
}
private showLayout(allCampaigns: Campaign[], data: CampaignTreeData): void {
const campaignId = this.campaign!.id!;
const globalItems: GlobalItem[] = allCampaigns.map(c => ({

View File

@@ -4,7 +4,7 @@ import { Router } from '@angular/router';
import { LucideAngularModule, Map, Plus } from 'lucide-angular';
import { CampaignService } from '../services/campaign.service';
import { Campaign } from '../services/campaign.model';
import { CampaignCreateComponent, CampaignCreatePayload } from './campaign-create/campaign-create.component';
import { CampaignCreateComponent, CampaignCreatePayload } from './campaign/campaign-create/campaign-create.component';
@Component({
selector: 'app-campaigns',

View File

@@ -4,13 +4,14 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angula
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { Campaign } from '../../services/campaign.model';
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { LayoutService, GlobalItem } from '../../../services/layout.service';
import { Campaign } from '../../../services/campaign.model';
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
/**
* Écran de création d'un nouveau chapitre rattaché à un arc.
@@ -39,6 +40,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private layoutService: LayoutService
) {
this.form = this.fb.group({
@@ -57,7 +59,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
}).subscribe(({ campaign, allCampaigns, treeData }) => {
const currentArc = treeData.arcs.find(a => a.id === this.arcId);
this.arcName = currentArc?.name ?? '';

View File

@@ -5,19 +5,20 @@ import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { PageService } from '../../services/page.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { Campaign, Chapter } from '../../services/campaign.model';
import { Page } from '../../services/page.model';
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { PageService } from '../../../services/page.service';
import { LayoutService, GlobalItem } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Campaign, Chapter } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model';
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
/**
* Écran de détail/modification d'un Chapitre.
@@ -67,6 +68,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
@@ -104,7 +106,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
chapter: this.campaignService.getChapterById(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
}).pipe(
switchMap(data => {
const lid = data.campaign.loreId ?? null;

View File

@@ -3,12 +3,13 @@ import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule, ArrowLeft } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { Campaign, Chapter, Scene } from '../../services/campaign.model';
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { LayoutService, GlobalItem } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Campaign, Chapter, Scene } from '../../../services/campaign.model';
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
interface GraphNode { id: string; name: string; displayName: string; x: number; y: number; }
interface GraphEdge { key: string; label: string; x1: number; y1: number; x2: number; y2: number; labelX: number; labelY: number; }
@@ -68,6 +69,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
) {}
@@ -87,7 +89,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
allCampaigns: this.campaignService.getAllCampaigns(),
chapter: this.campaignService.getChapterById(this.chapterId),
scenes: this.campaignService.getScenes(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
}).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => {
this.chapter = chapter;
this.scenes = scenes;

View File

@@ -4,16 +4,17 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Pencil, Network, Trash2 } from 'lucide-angular';
import { resolveCampaignIcon } from '../campaign-icons';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { PageService } from '../../services/page.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { Campaign, Chapter } from '../../services/campaign.model';
import { Page } from '../../services/page.model';
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
import { resolveCampaignIcon } from '../../campaign-icons';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { PageService } from '../../../services/page.service';
import { LayoutService, GlobalItem } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Campaign, Chapter } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model';
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
/**
* Écran de consultation d'un Chapitre (lecture seule).
@@ -45,6 +46,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
@@ -71,7 +73,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
chapter: this.campaignService.getChapterById(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
}).pipe(
switchMap(data => {
const lid = data.campaign.loreId ?? null;

View File

@@ -3,9 +3,9 @@ import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lucide-angular';
import { CharacterService } from '../../services/character.service';
import { Character } from '../../services/character.model';
import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { CharacterService } from '../../../services/character.service';
import { Character } from '../../../services/character.model';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
/**
* Éditeur plein écran d'une fiche de personnage (PJ).

View File

@@ -0,0 +1,82 @@
<div class="ne-page">
<div class="ne-header">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour à la campagne
</button>
<div class="header-row">
<h1>
<lucide-icon [img]="Drama" [size]="22"></lucide-icon>
{{ npcId ? 'Éditer le PNJ' : 'Nouveau PNJ' }}
</h1>
<button
*ngIf="npcId"
type="button"
class="btn-ai"
(click)="toggleChat()"
[class.active]="chatOpen"
title="Ouvrir l'Assistant IA pour dialoguer autour de ce PNJ">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
</div>
</div>
<div class="ne-form">
<div class="field">
<label>Nom du PNJ *</label>
<input
type="text"
[(ngModel)]="name"
name="name"
placeholder="Ex: Borin le forgeron, Dame Elara, Kael l'aubergiste..."
/>
</div>
<div class="field content-field">
<label>Fiche (markdown)</label>
<p class="hint">
Tout en markdown libre : apparence, motivations, faction, secrets, stats, notes MJ…
À terme, l'IA pourra exploiter ces infos pour incarner le PNJ avec cohérence dans les scènes.
</p>
<textarea
[(ngModel)]="markdownContent"
name="markdownContent"
rows="22"
placeholder="# Borin le forgeron&#10;&#10;**Race :** Nain&#10;**Faction :** Clan Feuillefer&#10;**Statut :** Vivant&#10;&#10;## Apparence&#10;Barbe rousse tressée, tablier de cuir brûlé...&#10;&#10;## Motivations&#10;Venger son clan décimé par les orcs il y a 10 hivers.&#10;&#10;## Notes MJ (secret)&#10;Connaît l'emplacement du marteau de Durin..."
></textarea>
</div>
<div class="actions">
<button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
{{ npcId ? 'Enregistrer' : 'Créer' }}
</button>
<button type="button" class="btn-secondary" (click)="back()">Annuler</button>
<span class="spacer"></span>
<button
*ngIf="npcId"
type="button"
class="btn-danger"
(click)="deleteNpc()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</div>
</div>
<app-ai-chat-drawer
*ngIf="npcId && campaignId"
[campaignId]="campaignId"
entityType="npc"
[entityId]="npcId"
[isOpen]="chatOpen"
welcomeMessage="Je vois cette fiche de PNJ. Demande-moi de proposer apparence, motivations, secrets, ou répliques signatures."
[quickSuggestions]="chatQuickSuggestions"
(close)="chatOpen = false">
</app-ai-chat-drawer>

View File

@@ -0,0 +1,157 @@
.ne-page {
padding: 2rem 3rem;
color: #e5e7eb;
max-width: 1000px;
margin: 0 auto;
}
.ne-header {
margin-bottom: 2rem;
.header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
h1 {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.75rem;
color: white;
margin: 0.75rem 0 0;
}
}
.btn-ai {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: rgba(167, 139, 250, 0.08);
border: 1px solid rgba(167, 139, 250, 0.4);
color: #a78bfa;
padding: 0.5rem 1rem;
border-radius: 8px;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.15s;
&:hover { background: rgba(167, 139, 250, 0.15); border-color: #a78bfa; }
&.active { background: #a78bfa; color: #0b1220; }
}
.btn-back {
background: transparent;
border: none;
color: #9ca3af;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0;
font-size: 0.85rem;
&:hover { color: #e5e7eb; }
}
.ne-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.field {
display: flex;
flex-direction: column;
label {
color: #e5e7eb;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.4rem;
}
.hint {
color: #6b7280;
font-size: 0.8rem;
margin: 0.4rem 0 0.5rem;
}
input[type="text"], textarea {
background: #0b1220;
border: 1px solid #1f2937;
border-radius: 8px;
color: #e5e7eb;
padding: 0.6rem 0.75rem;
font-size: 0.95rem;
font-family: inherit;
&:focus {
outline: none;
border-color: #a78bfa;
}
}
}
.content-field textarea {
font-family: 'Fira Code', 'Cascadia Code', monospace;
font-size: 0.85rem;
line-height: 1.5;
resize: vertical;
}
.actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
align-items: center;
.spacer { flex: 1; }
}
.btn-primary {
background: #a78bfa;
color: #0b1220;
border: none;
padding: 0.6rem 1.25rem;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.4rem;
&:disabled { opacity: 0.5; cursor: not-allowed; }
&:hover:not(:disabled) { background: #c4b5fd; }
}
.btn-secondary {
background: transparent;
border: 1px solid #1f2937;
color: #9ca3af;
padding: 0.6rem 1.25rem;
border-radius: 8px;
cursor: pointer;
&:hover { border-color: #374151; color: #e5e7eb; }
}
.btn-danger {
background: transparent;
border: 1px solid rgba(248, 113, 113, 0.3);
color: #f87171;
padding: 0.5rem 1rem;
border-radius: 8px;
cursor: pointer;
font-size: 0.875rem;
display: inline-flex;
align-items: center;
gap: 0.35rem;
&:hover {
border-color: #f87171;
background: rgba(248, 113, 113, 0.08);
}
}

View File

@@ -0,0 +1,109 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Save, ArrowLeft, Drama, Trash2, Sparkles } from 'lucide-angular';
import { NpcService } from '../../../services/npc.service';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
/**
* Éditeur plein écran d'une fiche de PNJ.
* Double rôle création/édition :
* - `/campaigns/:campaignId/npcs/create` → POST
* - `/campaigns/:campaignId/npcs/:npcId/edit` → PUT
*
* MVP : name + markdown libre. L'Assistant IA est branché en mode édition
* (focus entityType="npc") pour proposer apparence, motivations, secrets...
*/
@Component({
selector: 'app-npc-edit',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent],
templateUrl: './npc-edit.component.html',
styleUrls: ['./npc-edit.component.scss']
})
export class NpcEditComponent implements OnInit {
readonly Save = Save;
readonly ArrowLeft = ArrowLeft;
readonly Drama = Drama;
readonly Trash2 = Trash2;
readonly Sparkles = Sparkles;
/** État drawer chat IA focalisé sur ce PNJ. */
chatOpen = false;
readonly chatQuickSuggestions = [
'Propose une apparence et une posture marquantes',
'Suggère 2 motivations et un secret pour ce PNJ',
'Imagine 3 répliques signatures qui le caractérisent'
];
toggleChat(): void { this.chatOpen = !this.chatOpen; }
campaignId: string | null = null;
npcId: string | null = null;
name = '';
markdownContent = '';
private order = 0;
constructor(
private route: ActivatedRoute,
private router: Router,
private service: NpcService
) {}
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.name = n.name;
this.markdownContent = n.markdownContent ?? '';
this.order = n.order ?? 0;
},
error: () => this.back()
});
}
}
submit(): void {
if (!this.name.trim() || !this.campaignId) return;
const req = this.npcId
? this.service.update(this.npcId, {
id: this.npcId,
name: this.name.trim(),
markdownContent: this.markdownContent || null,
campaignId: this.campaignId,
order: this.order
})
: this.service.create({
name: this.name.trim(),
markdownContent: this.markdownContent || null,
campaignId: this.campaignId
});
req.subscribe({
next: () => this.back(),
error: () => console.error('Erreur sauvegarde Npc')
});
}
deleteNpc(): void {
if (!this.npcId) return;
if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irréversible.`)) return;
this.service.delete(this.npcId).subscribe({
next: () => this.back(),
error: () => console.error('Erreur suppression Npc')
});
}
back(): void {
if (this.campaignId) {
this.router.navigate(['/campaigns', this.campaignId]);
} else {
this.router.navigate(['/campaigns']);
}
}
}

View File

@@ -4,13 +4,14 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angula
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { Campaign } from '../../services/campaign.model';
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { LayoutService, GlobalItem } from '../../../services/layout.service';
import { Campaign } from '../../../services/campaign.model';
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
/**
* Écran de création d'une nouvelle scène rattachée à un chapitre.
@@ -40,6 +41,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private layoutService: LayoutService
) {
this.form = this.fb.group({
@@ -59,7 +61,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
}).subscribe(({ campaign, allCampaigns, treeData }) => {
const currentChapter = (treeData.chaptersByArc[this.arcId] ?? []).find(c => c.id === this.chapterId);
this.chapterName = currentChapter?.name ?? '';

View File

@@ -5,20 +5,21 @@ import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { PageService } from '../../services/page.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { Campaign, Scene, SceneBranch } from '../../services/campaign.model';
import { Page } from '../../services/page.model';
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
import { ExpandableSectionComponent } from '../../shared/expandable-section/expandable-section.component';
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { PageService } from '../../../services/page.service';
import { LayoutService, GlobalItem } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Campaign, Scene, SceneBranch } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model';
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
import { ExpandableSectionComponent } from '../../../shared/expandable-section/expandable-section.component';
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
/**
* Écran de détail/modification d'une Scène.
@@ -71,6 +72,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
@@ -122,7 +124,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
allCampaigns: this.campaignService.getAllCampaigns(),
scene: this.campaignService.getSceneById(this.sceneId),
chapterScenes: this.campaignService.getScenes(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
}).pipe(
switchMap(data => {
const lid = data.campaign.loreId ?? null;

View File

@@ -4,16 +4,17 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
import { resolveCampaignIcon } from '../campaign-icons';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { PageService } from '../../services/page.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { Campaign, Scene } from '../../services/campaign.model';
import { Page } from '../../services/page.model';
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
import { resolveCampaignIcon } from '../../campaign-icons';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { PageService } from '../../../services/page.service';
import { LayoutService, GlobalItem } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Campaign, Scene } from '../../../services/campaign.model';
import { Page } from '../../../services/page.model';
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
/**
* Écran de consultation d'une Scène (lecture seule).
@@ -45,6 +46,7 @@ export class SceneViewComponent implements OnInit, OnDestroy {
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
@@ -74,7 +76,7 @@ export class SceneViewComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
scene: this.campaignService.getSceneById(this.sceneId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
}).pipe(
switchMap(data => {
const lid = data.campaign.loreId ?? null;

View File

@@ -41,7 +41,7 @@ export type ChatStreamEvent =
* décode ligne par ligne pour extraire les événements SSE.
*/
/** Type d'entité narrative focus pour le chat Campagne. */
export type NarrativeEntityType = 'arc' | 'chapter' | 'scene' | 'character';
export type NarrativeEntityType = 'arc' | 'chapter' | 'scene' | 'character' | 'npc';
@Injectable({ providedIn: 'root' })
export class AiChatService {

View File

@@ -26,7 +26,7 @@ export interface Conversation {
export interface ConversationContext {
loreId?: string | null;
campaignId?: string | null;
entityType?: 'page' | 'arc' | 'chapter' | 'scene' | 'character' | null;
entityType?: 'page' | 'arc' | 'chapter' | 'scene' | 'character' | 'npc' | null;
entityId?: string | null;
}

View File

@@ -0,0 +1,18 @@
/**
* Fiche de personnage non-joueur (PNJ) d'une campagne.
* MVP : markdownContent libre (description, motivation, stats, notes MJ).
* Évolution prévue : templating partagé PJ/PNJ piloté par GameSystem.
*/
export interface Npc {
id?: string;
name: string;
markdownContent?: string | null;
campaignId: string;
order?: number;
}
export interface NpcCreate {
name: string;
markdownContent?: string | null;
campaignId: string;
}

View File

@@ -0,0 +1,34 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Npc, NpcCreate } from './npc.model';
/**
* Service HTTP pour les fiches de PNJ d'une campagne.
*/
@Injectable({ providedIn: 'root' })
export class NpcService {
private apiUrl = '/api/npcs';
constructor(private http: HttpClient) {}
getByCampaign(campaignId: string): Observable<Npc[]> {
return this.http.get<Npc[]>(`${this.apiUrl}/campaign/${campaignId}`);
}
getById(id: string): Observable<Npc> {
return this.http.get<Npc>(`${this.apiUrl}/${id}`);
}
create(payload: NpcCreate): Observable<Npc> {
return this.http.post<Npc>(this.apiUrl, payload);
}
update(id: string, payload: Npc): Observable<Npc> {
return this.http.put<Npc>(`${this.apiUrl}/${id}`, payload);
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
}