Ajout de la possibilité de faire des stats blocs pour tout ce qui est ennemis / créatures adverses.
All checks were successful
All checks were successful
Dorénavant, l'IA est capable de prendre en compte le format des quêtes, chapitres, Arc.... pour proposer des blocs plus complets. Les ennemis sont également référençables directement dans la campagne. Les références vers les ennemis dans la partie "donjon" est en cours d'ajout
This commit is contained in:
@@ -23,6 +23,7 @@ export const routes: Routes = [
|
||||
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId/characters/:characterId', loadComponent: () => import('./campaigns/character/character-view/character-view.component').then(m => m.CharacterViewComponent) },
|
||||
{ path: 'campaigns/:campaignId/npcs', loadComponent: () => import('./campaigns/npc/npc-list/npc-list.component').then(m => m.NpcListComponent) },
|
||||
{ path: 'campaigns/:campaignId/npcs/create', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/npcs/:npcId/edit', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/npcs/:npcId', loadComponent: () => import('./campaigns/npc/npc-view/npc-view.component').then(m => m.NpcViewComponent) },
|
||||
@@ -32,6 +33,10 @@ export const routes: Routes = [
|
||||
{ path: 'campaigns/:campaignId/item-catalogs/create', loadComponent: () => import('./campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component').then(m => m.ItemCatalogEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/item-catalogs/:catalogId/edit', loadComponent: () => import('./campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component').then(m => m.ItemCatalogEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/item-catalogs/:catalogId', loadComponent: () => import('./campaigns/item-catalog/item-catalog-view/item-catalog-view.component').then(m => m.ItemCatalogViewComponent) },
|
||||
{ path: 'campaigns/:campaignId/enemies', loadComponent: () => import('./campaigns/enemy/enemy-list/enemy-list.component').then(m => m.EnemyListComponent) },
|
||||
{ path: 'campaigns/:campaignId/enemies/create', loadComponent: () => import('./campaigns/enemy/enemy-edit/enemy-edit.component').then(m => m.EnemyEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/enemies/:enemyId/edit', loadComponent: () => import('./campaigns/enemy/enemy-edit/enemy-edit.component').then(m => m.EnemyEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/enemies/:enemyId', loadComponent: () => import('./campaigns/enemy/enemy-view/enemy-view.component').then(m => m.EnemyViewComponent) },
|
||||
{ path: 'campaigns/:campaignId/random-tables/create', loadComponent: () => import('./campaigns/random-table/random-table-edit/random-table-edit.component').then(m => m.RandomTableEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/random-tables/:tableId/edit', loadComponent: () => import('./campaigns/random-table/random-table-edit/random-table-edit.component').then(m => m.RandomTableEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/random-tables/:tableId', loadComponent: () => import('./campaigns/random-table/random-table-view/random-table-view.component').then(m => m.RandomTableViewComponent) },
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
import { LayoutService } from '../../../services/layout.service';
|
||||
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||
@@ -41,6 +42,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
private layoutService: LayoutService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
@@ -60,7 +62,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, this.npcService, this.randomTableService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||
this.existingArcCount = treeData.arcs.length;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
import { PageService } from '../../../services/page.service';
|
||||
import { LayoutService } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
@@ -78,6 +79,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
@@ -117,7 +119,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, this.npcService, this.randomTableService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
import { PageService } from '../../../services/page.service';
|
||||
import { LayoutService } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
@@ -60,6 +61,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
@@ -83,7 +85,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, this.npcService, this.randomTableService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
|
||||
@@ -4,11 +4,13 @@ import { CampaignService } from '../services/campaign.service';
|
||||
import { CharacterService } from '../services/character.service';
|
||||
import { NpcService } from '../services/npc.service';
|
||||
import { RandomTableService } from '../services/random-table.service';
|
||||
import { EnemyService } from '../services/enemy.service';
|
||||
import { TreeItem, SecondarySidebarConfig, GlobalItem } from '../services/layout.service';
|
||||
import { Arc, Chapter, Scene, Campaign } from '../services/campaign.model';
|
||||
import { Character } from '../services/character.model';
|
||||
import { Npc } from '../services/npc.model';
|
||||
import { RandomTable } from '../services/random-table.model';
|
||||
import { Enemy } from '../services/enemy.model';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
/**
|
||||
@@ -26,6 +28,7 @@ export interface CampaignTreeData {
|
||||
characters: Character[];
|
||||
npcs: Npc[];
|
||||
randomTables: RandomTable[];
|
||||
enemies: Enemy[];
|
||||
}
|
||||
|
||||
export function loadCampaignTreeData(
|
||||
@@ -35,7 +38,10 @@ export function loadCampaignTreeData(
|
||||
npcService: NpcService,
|
||||
// Optionnel pour ne pas casser les ~15 appelants existants : si fourni, les
|
||||
// tables aléatoires sont chargées et apparaissent dans la sidebar.
|
||||
randomTableService?: RandomTableService
|
||||
randomTableService?: RandomTableService,
|
||||
// Optionnel (même principe) : si fourni, les ennemis sont chargés et le nœud
|
||||
// « Ennemis » devient dépliable (dossiers → fiches) en plus du lien.
|
||||
enemyService?: EnemyService
|
||||
): Observable<CampaignTreeData> {
|
||||
// Note refonte Playthrough : les PJ appartiennent désormais à une Partie,
|
||||
// pas à la campagne — on ne les charge plus ici (les vues qui les affichent
|
||||
@@ -46,11 +52,14 @@ export function loadCampaignTreeData(
|
||||
npcs: npcService.getByCampaign(campaignId),
|
||||
randomTables: randomTableService
|
||||
? randomTableService.getByCampaign(campaignId).pipe(catchError(() => of([] as RandomTable[])))
|
||||
: of([] as RandomTable[])
|
||||
: of([] as RandomTable[]),
|
||||
enemies: enemyService
|
||||
? enemyService.getByCampaign(campaignId).pipe(catchError(() => of([] as Enemy[])))
|
||||
: of([] as Enemy[])
|
||||
}).pipe(
|
||||
switchMap(({ arcs, characters, npcs, randomTables }) => {
|
||||
switchMap(({ arcs, characters, npcs, randomTables, enemies }) => {
|
||||
if (arcs.length === 0) {
|
||||
return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters, npcs, randomTables });
|
||||
return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters, npcs, randomTables, enemies });
|
||||
}
|
||||
const chapterCalls = arcs.map(a =>
|
||||
service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters })))
|
||||
@@ -65,7 +74,7 @@ export function loadCampaignTreeData(
|
||||
});
|
||||
|
||||
if (allChapters.length === 0) {
|
||||
return of({ arcs, chaptersByArc, scenesByChapter: {}, characters, npcs, randomTables });
|
||||
return of({ arcs, chaptersByArc, scenesByChapter: {}, characters, npcs, randomTables, enemies });
|
||||
}
|
||||
const sceneCalls = allChapters.map(c =>
|
||||
service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes })))
|
||||
@@ -74,7 +83,7 @@ export function loadCampaignTreeData(
|
||||
map(sceneResults => {
|
||||
const scenesByChapter: Record<string, Scene[]> = {};
|
||||
sceneResults.forEach(r => { scenesByChapter[r.chapterId] = r.scenes; });
|
||||
return { arcs, chaptersByArc, scenesByChapter, characters, npcs, randomTables };
|
||||
return { arcs, chaptersByArc, scenesByChapter, characters, npcs, randomTables, enemies };
|
||||
})
|
||||
);
|
||||
})
|
||||
@@ -135,6 +144,9 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
||||
iconKey: 'c-drama',
|
||||
children: npcChildren,
|
||||
meta: sortedNpcs.length ? String(sortedNpcs.length) : undefined,
|
||||
// Cliquer le LIBELLÉ ouvre la page de liste (vue d'ensemble par dossiers) ;
|
||||
// cliquer le CHEVRON déplie l'arbre dans la sidebar — les deux coexistent.
|
||||
route: `/campaigns/${campaignId}/npcs`,
|
||||
// Porte le header de section "Personnages" (les PJ ayant migré vers la Partie).
|
||||
// Le filet au-dessus est masqué par CSS si c'est le tout premier item de la sidebar.
|
||||
sectionHeaderBefore: 'Personnages',
|
||||
@@ -146,6 +158,40 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
||||
}]
|
||||
};
|
||||
|
||||
// --- Ennemis (bestiaire) : même structure que les PNJ — dossiers dépliables
|
||||
// dans la sidebar + libellé cliquable vers la page de liste.
|
||||
const sortedEnemies = [...(data.enemies ?? [])].sort(byName);
|
||||
const enemyItem = (e: Enemy): TreeItem => ({
|
||||
id: `enemy-${e.id}`,
|
||||
label: e.name,
|
||||
route: `/campaigns/${campaignId}/enemies/${e.id}`,
|
||||
meta: e.level ? `Niv. ${e.level}` : undefined
|
||||
});
|
||||
const enemiesByFolder = new Map<string, Enemy[]>();
|
||||
const ungroupedEnemies: Enemy[] = [];
|
||||
for (const e of sortedEnemies) {
|
||||
const f = (e.folder ?? '').trim();
|
||||
if (f) {
|
||||
if (!enemiesByFolder.has(f)) enemiesByFolder.set(f, []);
|
||||
enemiesByFolder.get(f)!.push(e);
|
||||
} else {
|
||||
ungroupedEnemies.push(e);
|
||||
}
|
||||
}
|
||||
const enemyFolderNodes: TreeItem[] = [...enemiesByFolder.keys()]
|
||||
.sort((a, b) => a.localeCompare(b, 'fr', { sensitivity: 'base' }))
|
||||
.map(folder => {
|
||||
const items = enemiesByFolder.get(folder)!.map(enemyItem);
|
||||
return {
|
||||
id: `enemy-folder-${folder}`,
|
||||
label: folder,
|
||||
iconKey: 'folder',
|
||||
children: items,
|
||||
meta: String(items.length)
|
||||
};
|
||||
});
|
||||
const enemyChildren: TreeItem[] = [...enemyFolderNodes, ...ungroupedEnemies.map(enemyItem)];
|
||||
|
||||
const sortedArcs = [...data.arcs].sort(byName);
|
||||
|
||||
const arcNodes: TreeItem[] = sortedArcs.map((arc, idx) => {
|
||||
@@ -233,6 +279,24 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
||||
route: `/campaigns/${campaignId}/item-catalogs`
|
||||
};
|
||||
|
||||
// Ennemis (bestiaire, fiches pilotées par le template Ennemi du GameSystem,
|
||||
// classées par dossier) — rangé avec les PERSONNAGES, comme les PNJ.
|
||||
// Libellé → page de liste ; chevron → arbre dépliable (dossiers → fiches).
|
||||
const enemiesNode: TreeItem = {
|
||||
id: 'enemies-root',
|
||||
label: 'Ennemis',
|
||||
iconKey: 'skull',
|
||||
children: enemyChildren,
|
||||
meta: sortedEnemies.length ? String(sortedEnemies.length) : undefined,
|
||||
route: `/campaigns/${campaignId}/enemies`,
|
||||
createActions: [{
|
||||
id: 'new-enemy',
|
||||
label: 'Nouvel ennemi',
|
||||
route: `/campaigns/${campaignId}/enemies/create`,
|
||||
actionIcon: 'plus'
|
||||
}]
|
||||
};
|
||||
|
||||
// Importer un PDF de campagne → arborescence (outil, comme tables & ateliers).
|
||||
const importNode: TreeItem = {
|
||||
id: 'import-pdf-root',
|
||||
@@ -241,7 +305,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
||||
route: `/campaigns/${campaignId}/import`
|
||||
};
|
||||
|
||||
return [...arcNodes, npcsNode, tablesNode, notebooksNode, catalogsNode, importNode];
|
||||
return [...arcNodes, npcsNode, enemiesNode, tablesNode, notebooksNode, catalogsNode, importNode];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,7 @@ import { GameSystem } from '../../../services/game-system.model';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
import { SessionService } from '../../../services/session.service';
|
||||
import { PlaythroughService } from '../../../services/playthrough.service';
|
||||
import { Playthrough } from '../../../services/campaign.model';
|
||||
@@ -95,6 +96,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
private sessionService: SessionService,
|
||||
private playthroughService: PlaythroughService,
|
||||
private layoutService: LayoutService,
|
||||
@@ -112,8 +114,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, this.npcService, this.randomTableService).pipe(
|
||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [] } as CampaignTreeData))
|
||||
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService, this.enemyService).pipe(
|
||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [], enemies: [] } as CampaignTreeData))
|
||||
),
|
||||
playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[])))
|
||||
}))
|
||||
@@ -149,8 +151,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(id),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService).pipe(
|
||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [] } as CampaignTreeData))
|
||||
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService, this.enemyService).pipe(
|
||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [], enemies: [] } as CampaignTreeData))
|
||||
),
|
||||
playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[])))
|
||||
}).subscribe(({ campaign, allCampaigns, treeData, playthroughs }) => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
import { ArcKind, ArcProposal, ChapterProposal, NpcProposal, SceneProposal } from '../../../services/campaign-import.model';
|
||||
@@ -106,6 +107,7 @@ export class CampaignImportComponent implements OnInit {
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
private campaignSidebar: CampaignSidebarService,
|
||||
private pageTitle: PageTitleService
|
||||
) {}
|
||||
@@ -117,7 +119,7 @@ export class CampaignImportComponent implements OnInit {
|
||||
|
||||
// Pré-chargement de l'arborescence existante (pour fusionner à la revue).
|
||||
// En cas d'échec on dégrade : tout sera considéré comme nouveau.
|
||||
loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService)
|
||||
loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe(data => this.existingData = data);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
import { LayoutService } from '../../../services/layout.service';
|
||||
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||
@@ -43,6 +44,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
private layoutService: LayoutService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
@@ -61,7 +63,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, this.npcService, this.randomTableService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||
const currentArc = treeData.arcs.find(a => a.id === this.arcId);
|
||||
this.arcName = currentArc?.name ?? '';
|
||||
|
||||
@@ -9,6 +9,7 @@ import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
import { PageService } from '../../../services/page.service';
|
||||
import { LayoutService } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
@@ -92,6 +93,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
@@ -128,7 +130,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, this.npcService, this.randomTableService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
import { Campaign, Chapter, Scene } from '../../../services/campaign.model';
|
||||
@@ -71,6 +72,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
) {}
|
||||
@@ -90,7 +92,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, this.npcService, this.randomTableService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||
}).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => {
|
||||
this.chapter = chapter;
|
||||
this.scenes = scenes;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
import { PageService } from '../../../services/page.service';
|
||||
import { LayoutService } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
@@ -52,6 +53,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
@@ -79,7 +81,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, this.npcService, this.randomTableService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
|
||||
111
web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.html
Normal file
111
web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.html
Normal file
@@ -0,0 +1,111 @@
|
||||
<div class="ne-page">
|
||||
|
||||
<div class="ne-header">
|
||||
<button class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
Retour aux ennemis
|
||||
</button>
|
||||
<div class="header-row">
|
||||
<h1>
|
||||
<lucide-icon [img]="Skull" [size]="22"></lucide-icon>
|
||||
{{ enemyId ? 'Éditer l\'ennemi' : 'Nouvel ennemi' }}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ne-form">
|
||||
|
||||
<div class="field">
|
||||
<label for="enemy-name">Nom de l'ennemi *</label>
|
||||
<input
|
||||
id="enemy-name"
|
||||
type="text"
|
||||
[(ngModel)]="name"
|
||||
name="name"
|
||||
placeholder="Ex: Balor, Gobelin chef de guerre, Liche…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-row image-row">
|
||||
<div class="field">
|
||||
<label for="enemy-level">Niveau / FP</label>
|
||||
<input
|
||||
id="enemy-level"
|
||||
type="text"
|
||||
[(ngModel)]="level"
|
||||
name="level"
|
||||
placeholder="Ex: 5, FP 8, Boss…"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="enemy-folder">Dossier</label>
|
||||
<input
|
||||
id="enemy-folder"
|
||||
type="text"
|
||||
[(ngModel)]="folder"
|
||||
name="folder"
|
||||
list="enemy-folders"
|
||||
placeholder="Ex: Démons, Humanoïdes… (vide = non classé)"
|
||||
/>
|
||||
<datalist id="enemy-folders">
|
||||
@for (f of existingFolders; track f) {
|
||||
<option [value]="f"></option>
|
||||
}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row image-row">
|
||||
<div class="field portrait-field">
|
||||
<label>Portrait</label>
|
||||
<app-single-image-picker
|
||||
[imageId]="portraitImageId"
|
||||
aspectRatio="1 / 1"
|
||||
hint="Carre conseille (400×400)."
|
||||
(imageIdChange)="portraitImageId = $event">
|
||||
</app-single-image-picker>
|
||||
</div>
|
||||
<div class="field header-field">
|
||||
<label>Bandeau / Header</label>
|
||||
<app-single-image-picker
|
||||
[imageId]="headerImageId"
|
||||
aspectRatio="3 / 1"
|
||||
hint="Format paysage conseille (1200×400)."
|
||||
(imageIdChange)="headerImageId = $event">
|
||||
</app-single-image-picker>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="template-fields">
|
||||
<app-dynamic-fields-form
|
||||
[fields]="templateFields"
|
||||
[values]="values"
|
||||
[imageValues]="imageValues"
|
||||
[keyValueValues]="keyValueValues"
|
||||
(valuesChange)="values = $event"
|
||||
(imageValuesChange)="imageValues = $event"
|
||||
(keyValueValuesChange)="keyValueValues = $event">
|
||||
</app-dynamic-fields-form>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
|
||||
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
|
||||
{{ enemyId ? 'Enregistrer' : 'Créer' }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="back()">Annuler</button>
|
||||
<span class="spacer"></span>
|
||||
@if (enemyId) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
(click)="deleteEnemy()">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
Supprimer
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
157
web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.scss
Normal file
157
web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
167
web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.ts
Normal file
167
web/src/app/campaigns/enemy/enemy-edit/enemy-edit.component.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, Save, ArrowLeft, Skull, Trash2 } from 'lucide-angular';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { GameSystemService } from '../../../services/game-system.service';
|
||||
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||
import { TemplateField } from '../../../services/template.model';
|
||||
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
|
||||
import { SingleImagePickerComponent } from '../../../shared/single-image-picker/single-image-picker.component';
|
||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
/**
|
||||
* Editeur plein écran d'une fiche d'ennemi (bestiaire). Même principe que
|
||||
* NpcEditComponent : formulaire dynamique piloté par le template ENNEMI du
|
||||
* GameSystem associé à la campagne, + champs universels niveau/dossier.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-enemy-edit',
|
||||
imports: [FormsModule, LucideAngularModule, DynamicFieldsFormComponent, SingleImagePickerComponent],
|
||||
templateUrl: './enemy-edit.component.html',
|
||||
styleUrls: ['./enemy-edit.component.scss']
|
||||
})
|
||||
export class EnemyEditComponent implements OnInit {
|
||||
readonly Save = Save;
|
||||
readonly ArrowLeft = ArrowLeft;
|
||||
readonly Skull = Skull;
|
||||
readonly Trash2 = Trash2;
|
||||
|
||||
campaignId: string | null = null;
|
||||
enemyId: string | null = null;
|
||||
|
||||
name = '';
|
||||
level = '';
|
||||
folder = '';
|
||||
/** Dossiers déjà utilisés dans la campagne (datalist d'auto-complétion). */
|
||||
existingFolders: string[] = [];
|
||||
portraitImageId: string | null = null;
|
||||
headerImageId: string | null = null;
|
||||
values: Record<string, string> = {};
|
||||
imageValues: Record<string, string[]> = {};
|
||||
keyValueValues: Record<string, Record<string, string>> = {};
|
||||
templateFields: TemplateField[] = [];
|
||||
private order = 0;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private service: EnemyService,
|
||||
private campaignService: CampaignService,
|
||||
private gameSystemService: GameSystemService,
|
||||
private campaignSidebar: CampaignSidebarService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
const params = this.route.snapshot.paramMap;
|
||||
this.campaignId = params.get('campaignId');
|
||||
this.enemyId = params.get('enemyId');
|
||||
|
||||
if (this.campaignId) {
|
||||
this.loadTemplateForCampaign(this.campaignId);
|
||||
this.campaignSidebar.show(this.campaignId);
|
||||
this.loadExistingFolders(this.campaignId);
|
||||
}
|
||||
|
||||
if (this.enemyId) {
|
||||
this.service.getById(this.enemyId).subscribe({
|
||||
next: (e) => {
|
||||
this.name = e.name;
|
||||
this.level = e.level ?? '';
|
||||
this.folder = e.folder ?? '';
|
||||
this.portraitImageId = e.portraitImageId ?? null;
|
||||
this.headerImageId = e.headerImageId ?? null;
|
||||
this.values = e.values ?? {};
|
||||
this.imageValues = e.imageValues ?? {};
|
||||
this.keyValueValues = e.keyValueValues ?? {};
|
||||
this.order = e.order ?? 0;
|
||||
},
|
||||
error: () => this.back()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private loadExistingFolders(campaignId: string): void {
|
||||
this.service.getByCampaign(campaignId).subscribe({
|
||||
next: (list) => {
|
||||
this.existingFolders = [...new Set(
|
||||
list.map(e => (e.folder ?? '').trim()).filter(f => f.length > 0)
|
||||
)].sort((a, b) => a.localeCompare(b, 'fr'));
|
||||
},
|
||||
error: () => { this.existingFolders = []; }
|
||||
});
|
||||
}
|
||||
|
||||
private loadTemplateForCampaign(campaignId: string): void {
|
||||
this.campaignService.getCampaignById(campaignId).subscribe({
|
||||
next: (campaign) => {
|
||||
if (!campaign.gameSystemId) {
|
||||
this.templateFields = [];
|
||||
return;
|
||||
}
|
||||
this.gameSystemService.getById(campaign.gameSystemId).subscribe({
|
||||
next: (gs) => { this.templateFields = gs.enemyTemplate ?? []; },
|
||||
error: () => { this.templateFields = []; }
|
||||
});
|
||||
},
|
||||
error: () => { this.templateFields = []; }
|
||||
});
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (!this.name.trim() || !this.campaignId) return;
|
||||
const payload = {
|
||||
name: this.name.trim(),
|
||||
level: this.level.trim() || null,
|
||||
folder: this.folder.trim() || null,
|
||||
portraitImageId: this.portraitImageId,
|
||||
headerImageId: this.headerImageId,
|
||||
values: this.values,
|
||||
imageValues: this.imageValues,
|
||||
keyValueValues: this.keyValueValues,
|
||||
campaignId: this.campaignId
|
||||
};
|
||||
const isCreation = !this.enemyId;
|
||||
const req = this.enemyId
|
||||
? this.service.update(this.enemyId, { ...payload, id: this.enemyId, order: this.order })
|
||||
: this.service.create(payload);
|
||||
req.subscribe({
|
||||
next: (saved) => {
|
||||
if (isCreation && saved.id) {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'enemies', saved.id]);
|
||||
} else {
|
||||
this.back();
|
||||
}
|
||||
},
|
||||
error: () => console.error('Erreur sauvegarde Enemy')
|
||||
});
|
||||
}
|
||||
|
||||
deleteEnemy(): void {
|
||||
if (!this.enemyId) return;
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Supprimer la fiche ?',
|
||||
message: `Supprimer la fiche de "${this.name}" ?`,
|
||||
details: ['Cette action est irreversible.'],
|
||||
confirmLabel: 'Supprimer',
|
||||
variant: 'danger'
|
||||
}).then(ok => {
|
||||
if (!ok || !this.enemyId) return;
|
||||
this.service.delete(this.enemyId).subscribe({
|
||||
next: () => this.back(),
|
||||
error: () => console.error('Erreur suppression Enemy')
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
back(): void {
|
||||
if (this.campaignId) {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'enemies']);
|
||||
} else {
|
||||
this.router.navigate(['/campaigns']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<div class="sbl-page">
|
||||
<div class="sbl-toolbar">
|
||||
<button class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Retour
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn-create" (click)="create()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> Nouvel ennemi
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<header class="sbl-header">
|
||||
<h1><lucide-icon [img]="Skull" [size]="22"></lucide-icon> Ennemis</h1>
|
||||
<p class="sbl-hint">
|
||||
Le bestiaire de la campagne : monstres et créatures avec leur fiche (pilotée par le
|
||||
template Ennemi du système de jeu), classés par dossier (Démons, Humanoïdes…).
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="sbl-list">
|
||||
@if (total === 0) {
|
||||
<p class="empty">Aucun ennemi pour l'instant.</p>
|
||||
}
|
||||
@for (g of groups; track g.folder) {
|
||||
@if (g.folder) {
|
||||
<h2 class="sbl-folder">
|
||||
<lucide-icon [img]="Folder" [size]="14"></lucide-icon>
|
||||
{{ g.folder }}
|
||||
<span class="sbl-folder-count">{{ g.enemies.length }}</span>
|
||||
</h2>
|
||||
} @else if (groups.length > 1) {
|
||||
<h2 class="sbl-folder sbl-folder--none">Non classés</h2>
|
||||
}
|
||||
@for (e of g.enemies; track e.id) {
|
||||
<button class="sbl-item" (click)="open(e)">
|
||||
<lucide-icon [img]="Skull" [size]="16"></lucide-icon>
|
||||
<span class="sbl-item-name">{{ e.name }}</span>
|
||||
@if (e.level) {
|
||||
<span class="sbl-item-level">Niv. {{ e.level }}</span>
|
||||
}
|
||||
<span class="sbl-del" (click)="remove(e, $event)" title="Supprimer">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,53 @@
|
||||
.sbl-page { max-width: 760px; margin: 0 auto; padding: 1rem 1.5rem 3rem; }
|
||||
|
||||
.sbl-toolbar {
|
||||
display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem;
|
||||
.spacer { flex: 1; }
|
||||
button {
|
||||
display: inline-flex; align-items: center; gap: 0.35rem;
|
||||
padding: 0.4rem 0.8rem; border-radius: 6px;
|
||||
border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04);
|
||||
color: inherit; cursor: pointer; font-size: 0.85rem;
|
||||
&:hover { background: rgba(255,255,255,0.09); }
|
||||
}
|
||||
.btn-create { background: #667eea; border-color: #667eea; color: #fff; }
|
||||
.btn-create:hover { background: #5568d3; }
|
||||
}
|
||||
|
||||
.sbl-header {
|
||||
margin-bottom: 1.25rem;
|
||||
h1 { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.35rem; font-size: 1.5rem; }
|
||||
.sbl-hint { margin: 0; color: var(--color-text-muted, #9aa0aa); font-size: 0.9rem; }
|
||||
}
|
||||
|
||||
.sbl-list { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.empty { color: var(--color-text-muted, #9aa0aa); font-style: italic; }
|
||||
|
||||
// En-tête de dossier (Démons, Humanoïdes…) — sépare visuellement les groupes.
|
||||
.sbl-folder {
|
||||
display: flex; align-items: center; gap: 0.4rem;
|
||||
margin: 0.9rem 0 0.15rem; font-size: 0.78rem; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
color: #a5b4fc;
|
||||
|
||||
.sbl-folder-count {
|
||||
font-weight: 400; color: var(--color-text-muted, #9aa0aa);
|
||||
}
|
||||
|
||||
&.sbl-folder--none { color: var(--color-text-muted, #9aa0aa); }
|
||||
}
|
||||
|
||||
.sbl-item {
|
||||
display: flex; align-items: center; gap: 0.55rem;
|
||||
padding: 0.7rem 0.85rem; border-radius: 8px;
|
||||
border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03);
|
||||
color: inherit; cursor: pointer; text-align: left;
|
||||
&:hover { background: rgba(255,255,255,0.07); }
|
||||
.sbl-item-name { flex: 1; font-weight: 500; }
|
||||
.sbl-item-level {
|
||||
font-size: 0.74rem; padding: 0.12rem 0.5rem; border-radius: 999px;
|
||||
background: rgba(224,90,90,0.12); border: 1px solid rgba(224,90,90,0.35); color: #fca5a5;
|
||||
}
|
||||
.sbl-del { display: inline-flex; padding: 0.25rem; border-radius: 5px; color: #e88;
|
||||
&:hover { background: rgba(224,90,90,0.15); } }
|
||||
}
|
||||
107
web/src/app/campaigns/enemy/enemy-list/enemy-list.component.ts
Normal file
107
web/src/app/campaigns/enemy/enemy-list/enemy-list.component.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { LucideAngularModule, ArrowLeft, Plus, Trash2, Skull, Folder } from 'lucide-angular';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||
import { Enemy } from '../../../services/enemy.model';
|
||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
/** Groupe d'affichage : un dossier (« Démons »…) et ses ennemis. */
|
||||
interface FolderGroup {
|
||||
folder: string;
|
||||
enemies: Enemy[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste des ennemis d'une campagne, groupés par dossier (comme les PNJ).
|
||||
* Route : /campaigns/:campaignId/enemies
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-enemy-list',
|
||||
imports: [LucideAngularModule],
|
||||
templateUrl: './enemy-list.component.html',
|
||||
styleUrls: ['./enemy-list.component.scss']
|
||||
})
|
||||
export class EnemyListComponent implements OnInit {
|
||||
readonly ArrowLeft = ArrowLeft;
|
||||
readonly Plus = Plus;
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Skull = Skull;
|
||||
readonly Folder = Folder;
|
||||
|
||||
campaignId = '';
|
||||
/** Groupes triés par nom de dossier ; les non-classés en dernier (folder = ''). */
|
||||
groups: FolderGroup[] = [];
|
||||
total = 0;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private service: EnemyService,
|
||||
private campaignSidebar: CampaignSidebarService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.campaignId = this.route.snapshot.paramMap.get('campaignId') ?? '';
|
||||
if (this.campaignId) {
|
||||
this.campaignSidebar.show(this.campaignId);
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.service.getByCampaign(this.campaignId).subscribe({
|
||||
next: (list) => this.groups = this.groupByFolder(list),
|
||||
error: () => this.groups = []
|
||||
});
|
||||
}
|
||||
|
||||
/** Même logique de groupement que les PNJ de la sidebar : dossiers triés, non-classés à la fin. */
|
||||
private groupByFolder(enemies: Enemy[]): FolderGroup[] {
|
||||
this.total = enemies.length;
|
||||
const sorted = [...enemies].sort((a, b) => a.name.localeCompare(b.name, 'fr'));
|
||||
const byFolder = new Map<string, Enemy[]>();
|
||||
const ungrouped: Enemy[] = [];
|
||||
for (const e of sorted) {
|
||||
const f = (e.folder ?? '').trim();
|
||||
if (f) {
|
||||
if (!byFolder.has(f)) byFolder.set(f, []);
|
||||
byFolder.get(f)!.push(e);
|
||||
} else {
|
||||
ungrouped.push(e);
|
||||
}
|
||||
}
|
||||
const groups: FolderGroup[] = [...byFolder.keys()]
|
||||
.sort((a, b) => a.localeCompare(b, 'fr'))
|
||||
.map(folder => ({ folder, enemies: byFolder.get(folder)! }));
|
||||
if (ungrouped.length) groups.push({ folder: '', enemies: ungrouped });
|
||||
return groups;
|
||||
}
|
||||
|
||||
create(): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'enemies', 'create']);
|
||||
}
|
||||
|
||||
open(e: Enemy): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'enemies', e.id]);
|
||||
}
|
||||
|
||||
remove(e: Enemy, ev: Event): void {
|
||||
ev.stopPropagation();
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Supprimer l\'ennemi',
|
||||
message: `Supprimer « ${e.name} » ?`,
|
||||
confirmLabel: 'Supprimer',
|
||||
variant: 'danger'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
this.service.delete(e.id!).subscribe(() => this.load());
|
||||
});
|
||||
}
|
||||
|
||||
back(): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<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-edit" (click)="edit()">
|
||||
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon>
|
||||
Editer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<app-persona-view [persona]="enemy" [templateFields]="templateFields" [subtitle]="subtitle"></app-persona-view>
|
||||
</div>
|
||||
@@ -0,0 +1,94 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
// Pages de Lore liées au PNJ — chips cliquables sous la fiche.
|
||||
.nv-lore-links {
|
||||
max-width: 1100px;
|
||||
margin: 24px auto 0;
|
||||
padding: 0 32px;
|
||||
|
||||
.nv-lore-links-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: #a5b4fc;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
|
||||
.nv-lore-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.nv-lore-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.3rem 0.7rem;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
border-radius: 999px;
|
||||
color: #d1d5db;
|
||||
font-size: 0.82rem;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #6c63ff;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { LucideAngularModule, ArrowLeft, Edit3 } from 'lucide-angular';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { GameSystemService } from '../../../services/game-system.service';
|
||||
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||
import { TemplateField } from '../../../services/template.model';
|
||||
import { Enemy } from '../../../services/enemy.model';
|
||||
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
|
||||
|
||||
/**
|
||||
* Vue lecture seule d'une fiche d'ennemi (même rendu « persona » que les PNJ).
|
||||
* Route : /campaigns/:campaignId/enemies/:enemyId
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-enemy-view',
|
||||
imports: [LucideAngularModule, PersonaViewComponent],
|
||||
templateUrl: './enemy-view.component.html',
|
||||
styleUrls: ['./enemy-view.component.scss']
|
||||
})
|
||||
export class EnemyViewComponent implements OnInit, OnDestroy {
|
||||
readonly ArrowLeft = ArrowLeft;
|
||||
readonly Edit3 = Edit3;
|
||||
|
||||
campaignId: string | null = null;
|
||||
enemyId: string | null = null;
|
||||
|
||||
enemy: Enemy | null = null;
|
||||
templateFields: TemplateField[] = [];
|
||||
|
||||
private paramsSub?: Subscription;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private service: EnemyService,
|
||||
private campaignService: CampaignService,
|
||||
private gameSystemService: GameSystemService,
|
||||
private campaignSidebar: CampaignSidebarService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
// paramMap (pas snapshot) : Angular réutilise le composant entre 2 ennemis.
|
||||
this.paramsSub = this.route.paramMap.subscribe(params => {
|
||||
const newCampaignId = params.get('campaignId');
|
||||
this.enemyId = params.get('enemyId');
|
||||
|
||||
if (this.enemyId) {
|
||||
this.service.getById(this.enemyId).subscribe({
|
||||
next: e => { this.enemy = e; },
|
||||
error: () => this.back()
|
||||
});
|
||||
}
|
||||
|
||||
if (newCampaignId && newCampaignId !== this.campaignId) {
|
||||
this.campaignId = newCampaignId;
|
||||
this.campaignSidebar.show(this.campaignId);
|
||||
this.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
|
||||
if (camp.gameSystemId) {
|
||||
this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => {
|
||||
this.templateFields = gs.enemyTemplate ?? [];
|
||||
});
|
||||
}
|
||||
});
|
||||
} else if (newCampaignId) {
|
||||
this.campaignId = newCampaignId;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.paramsSub?.unsubscribe();
|
||||
}
|
||||
|
||||
/** Sous-titre de la fiche : niveau + dossier (« Niveau 8 · Démons »). */
|
||||
get subtitle(): string {
|
||||
const parts: string[] = [];
|
||||
if (this.enemy?.level) parts.push(`Niveau ${this.enemy.level}`);
|
||||
if (this.enemy?.folder) parts.push(this.enemy.folder);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
edit(): void {
|
||||
if (this.campaignId && this.enemyId) {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'enemies', this.enemyId, 'edit']);
|
||||
}
|
||||
}
|
||||
|
||||
back(): void {
|
||||
if (this.campaignId) {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'enemies']);
|
||||
} else {
|
||||
this.router.navigate(['/campaigns']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,10 +103,19 @@ export class NotebookActionCardComponent implements OnInit {
|
||||
}
|
||||
|
||||
private createNpc(): void {
|
||||
// Valeurs de fiche proposées par l'IA (clés = champs du template PNJ) ;
|
||||
// la description sert de repli si le modèle n'a pas détaillé par champ.
|
||||
const values: Record<string, string> = {};
|
||||
for (const [key, val] of Object.entries(this.action.values ?? {})) {
|
||||
if (typeof val === 'string' && val.trim()) values[key] = val;
|
||||
}
|
||||
if (Object.keys(values).length === 0 && this.action.description) {
|
||||
values['Description'] = this.action.description;
|
||||
}
|
||||
this.npcService.create({
|
||||
name: this.action.name,
|
||||
campaignId: this.campaignId,
|
||||
values: this.action.description ? { Description: this.action.description } : {}
|
||||
values
|
||||
}).subscribe({
|
||||
next: (n) => this.done(['/campaigns', this.campaignId, 'npcs', n.id!]),
|
||||
error: (e) => this.fail(e)
|
||||
@@ -119,7 +128,12 @@ export class NotebookActionCardComponent implements OnInit {
|
||||
description: this.action.description,
|
||||
campaignId: this.campaignId,
|
||||
order: this.arcs.length,
|
||||
type: this.action.arcType === 'HUB' ? 'HUB' : 'LINEAR'
|
||||
type: this.action.arcType === 'HUB' ? 'HUB' : 'LINEAR',
|
||||
themes: this.action.themes,
|
||||
stakes: this.action.stakes,
|
||||
rewards: this.action.rewards,
|
||||
resolution: this.action.resolution,
|
||||
gmNotes: this.action.gmNotes
|
||||
}).subscribe({
|
||||
next: (a) => this.done(['/campaigns', this.campaignId, 'arcs', a.id!]),
|
||||
error: (e) => this.fail(e)
|
||||
@@ -132,7 +146,10 @@ export class NotebookActionCardComponent implements OnInit {
|
||||
name: this.action.name,
|
||||
description: this.action.description,
|
||||
arcId: this.selectedArcId,
|
||||
order
|
||||
order,
|
||||
playerObjectives: this.action.playerObjectives,
|
||||
narrativeStakes: this.action.narrativeStakes,
|
||||
gmNotes: this.action.gmNotes
|
||||
}).subscribe({
|
||||
next: (c) => this.done(['/campaigns', this.campaignId, 'arcs', this.selectedArcId, 'chapters', c.id!]),
|
||||
error: (e) => this.fail(e)
|
||||
@@ -143,9 +160,17 @@ export class NotebookActionCardComponent implements OnInit {
|
||||
this.campaignService.createScene({
|
||||
name: this.action.name,
|
||||
description: this.action.description,
|
||||
playerNarration: this.action.content,
|
||||
// `content` = ancien protocole (messages archivés d'avant l'enrichissement).
|
||||
playerNarration: this.action.playerNarration ?? this.action.content,
|
||||
chapterId: this.selectedChapterId,
|
||||
order: 0
|
||||
order: 0,
|
||||
location: this.action.location,
|
||||
timing: this.action.timing,
|
||||
atmosphere: this.action.atmosphere,
|
||||
gmSecretNotes: this.action.gmSecretNotes,
|
||||
choicesConsequences: this.action.choicesConsequences,
|
||||
combatDifficulty: this.action.combatDifficulty,
|
||||
enemies: this.action.enemies
|
||||
}).subscribe({
|
||||
next: (s) => this.done(
|
||||
['/campaigns', this.campaignId, 'arcs', this.selectedArcId, 'chapters', this.selectedChapterId, 'scenes', s.id!]),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CampaignSidebarService } from '../../../services/campaign-sidebar.servi
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
import { NotebookArchive, NotebookDetail, NotebookSource, NotebookMessage } from '../../../services/notebook.model';
|
||||
import { parseNotebookActions, NotebookAction } from '../../../services/notebook-action.model';
|
||||
import { Arc, Chapter } from '../../../services/campaign.model';
|
||||
@@ -66,6 +67,7 @@ export class NotebookDetailComponent implements OnInit {
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private enemyService: EnemyService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
) {}
|
||||
|
||||
@@ -80,7 +82,7 @@ export class NotebookDetailComponent implements OnInit {
|
||||
}
|
||||
|
||||
private loadTree(): void {
|
||||
loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
|
||||
loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, undefined, this.enemyService)
|
||||
.subscribe({
|
||||
next: (data) => { this.arcs = data.arcs; this.chaptersByArc = data.chaptersByArc; },
|
||||
error: () => { /* cibles indisponibles : les cartes le signaleront */ }
|
||||
|
||||
45
web/src/app/campaigns/npc/npc-list/npc-list.component.html
Normal file
45
web/src/app/campaigns/npc/npc-list/npc-list.component.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<div class="sbl-page">
|
||||
<div class="sbl-toolbar">
|
||||
<button class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Retour
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn-create" (click)="create()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> Nouveau PNJ
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<header class="sbl-header">
|
||||
<h1><lucide-icon [img]="Drama" [size]="22"></lucide-icon> PNJ</h1>
|
||||
<p class="sbl-hint">
|
||||
Tous les personnages non-joueurs de la campagne, classés par dossier.
|
||||
Le champ « Dossier » de chaque fiche pilote le classement.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="sbl-list">
|
||||
@if (total === 0) {
|
||||
<p class="empty">Aucun PNJ pour l'instant.</p>
|
||||
}
|
||||
@for (g of groups; track g.folder) {
|
||||
@if (g.folder) {
|
||||
<h2 class="sbl-folder">
|
||||
<lucide-icon [img]="Folder" [size]="14"></lucide-icon>
|
||||
{{ g.folder }}
|
||||
<span class="sbl-folder-count">{{ g.npcs.length }}</span>
|
||||
</h2>
|
||||
} @else if (groups.length > 1) {
|
||||
<h2 class="sbl-folder sbl-folder--none">Non classés</h2>
|
||||
}
|
||||
@for (n of g.npcs; track n.id) {
|
||||
<button class="sbl-item" (click)="open(n)">
|
||||
<lucide-icon [img]="Drama" [size]="16"></lucide-icon>
|
||||
<span class="sbl-item-name">{{ n.name }}</span>
|
||||
<span class="sbl-del" (click)="remove(n, $event)" title="Supprimer">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
53
web/src/app/campaigns/npc/npc-list/npc-list.component.scss
Normal file
53
web/src/app/campaigns/npc/npc-list/npc-list.component.scss
Normal file
@@ -0,0 +1,53 @@
|
||||
.sbl-page { max-width: 760px; margin: 0 auto; padding: 1rem 1.5rem 3rem; }
|
||||
|
||||
.sbl-toolbar {
|
||||
display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem;
|
||||
.spacer { flex: 1; }
|
||||
button {
|
||||
display: inline-flex; align-items: center; gap: 0.35rem;
|
||||
padding: 0.4rem 0.8rem; border-radius: 6px;
|
||||
border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04);
|
||||
color: inherit; cursor: pointer; font-size: 0.85rem;
|
||||
&:hover { background: rgba(255,255,255,0.09); }
|
||||
}
|
||||
.btn-create { background: #667eea; border-color: #667eea; color: #fff; }
|
||||
.btn-create:hover { background: #5568d3; }
|
||||
}
|
||||
|
||||
.sbl-header {
|
||||
margin-bottom: 1.25rem;
|
||||
h1 { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.35rem; font-size: 1.5rem; }
|
||||
.sbl-hint { margin: 0; color: var(--color-text-muted, #9aa0aa); font-size: 0.9rem; }
|
||||
}
|
||||
|
||||
.sbl-list { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.empty { color: var(--color-text-muted, #9aa0aa); font-style: italic; }
|
||||
|
||||
// En-tête de dossier (Démons, Humanoïdes…) — sépare visuellement les groupes.
|
||||
.sbl-folder {
|
||||
display: flex; align-items: center; gap: 0.4rem;
|
||||
margin: 0.9rem 0 0.15rem; font-size: 0.78rem; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
color: #a5b4fc;
|
||||
|
||||
.sbl-folder-count {
|
||||
font-weight: 400; color: var(--color-text-muted, #9aa0aa);
|
||||
}
|
||||
|
||||
&.sbl-folder--none { color: var(--color-text-muted, #9aa0aa); }
|
||||
}
|
||||
|
||||
.sbl-item {
|
||||
display: flex; align-items: center; gap: 0.55rem;
|
||||
padding: 0.7rem 0.85rem; border-radius: 8px;
|
||||
border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03);
|
||||
color: inherit; cursor: pointer; text-align: left;
|
||||
&:hover { background: rgba(255,255,255,0.07); }
|
||||
.sbl-item-name { flex: 1; font-weight: 500; }
|
||||
.sbl-item-level {
|
||||
font-size: 0.74rem; padding: 0.12rem 0.5rem; border-radius: 999px;
|
||||
background: rgba(224,90,90,0.12); border: 1px solid rgba(224,90,90,0.35); color: #fca5a5;
|
||||
}
|
||||
.sbl-del { display: inline-flex; padding: 0.25rem; border-radius: 5px; color: #e88;
|
||||
&:hover { background: rgba(224,90,90,0.15); } }
|
||||
}
|
||||
109
web/src/app/campaigns/npc/npc-list/npc-list.component.ts
Normal file
109
web/src/app/campaigns/npc/npc-list/npc-list.component.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { LucideAngularModule, ArrowLeft, Plus, Trash2, Drama, Folder } from 'lucide-angular';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||
import { Npc } from '../../../services/npc.model';
|
||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
/** Groupe d'affichage : un dossier (« Bard's Gate »…) et ses PNJ. */
|
||||
interface FolderGroup {
|
||||
folder: string;
|
||||
npcs: Npc[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vue d'ensemble des PNJ d'une campagne, groupés par dossier — pendant « page »
|
||||
* de l'arbre dépliable de la sidebar (les deux modes coexistent).
|
||||
* Route : /campaigns/:campaignId/npcs
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-npc-list',
|
||||
imports: [LucideAngularModule],
|
||||
templateUrl: './npc-list.component.html',
|
||||
styleUrls: ['./npc-list.component.scss']
|
||||
})
|
||||
export class NpcListComponent implements OnInit {
|
||||
readonly ArrowLeft = ArrowLeft;
|
||||
readonly Plus = Plus;
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Drama = Drama;
|
||||
readonly Folder = Folder;
|
||||
|
||||
campaignId = '';
|
||||
/** Groupes triés par nom de dossier ; les non-classés en dernier (folder = ''). */
|
||||
groups: FolderGroup[] = [];
|
||||
total = 0;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private service: NpcService,
|
||||
private campaignSidebar: CampaignSidebarService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.campaignId = this.route.snapshot.paramMap.get('campaignId') ?? '';
|
||||
if (this.campaignId) {
|
||||
this.campaignSidebar.show(this.campaignId);
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.service.getByCampaign(this.campaignId).subscribe({
|
||||
next: (list) => this.groups = this.groupByFolder(list),
|
||||
error: () => this.groups = []
|
||||
});
|
||||
}
|
||||
|
||||
/** Même logique de groupement que la sidebar : dossiers triés, non-classés à la fin. */
|
||||
private groupByFolder(npcs: Npc[]): FolderGroup[] {
|
||||
this.total = npcs.length;
|
||||
const sorted = [...npcs].sort((a, b) => a.name.localeCompare(b.name, 'fr'));
|
||||
const byFolder = new Map<string, Npc[]>();
|
||||
const ungrouped: Npc[] = [];
|
||||
for (const n of sorted) {
|
||||
const f = (n.folder ?? '').trim();
|
||||
if (f) {
|
||||
if (!byFolder.has(f)) byFolder.set(f, []);
|
||||
byFolder.get(f)!.push(n);
|
||||
} else {
|
||||
ungrouped.push(n);
|
||||
}
|
||||
}
|
||||
const groups: FolderGroup[] = [...byFolder.keys()]
|
||||
.sort((a, b) => a.localeCompare(b, 'fr'))
|
||||
.map(folder => ({ folder, npcs: byFolder.get(folder)! }));
|
||||
if (ungrouped.length) groups.push({ folder: '', npcs: ungrouped });
|
||||
return groups;
|
||||
}
|
||||
|
||||
create(): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'npcs', 'create']);
|
||||
}
|
||||
|
||||
open(n: Npc): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'npcs', n.id]);
|
||||
}
|
||||
|
||||
remove(n: Npc, ev: Event): void {
|
||||
ev.stopPropagation();
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Supprimer la fiche',
|
||||
message: `Supprimer la fiche de « ${n.name} » ?`,
|
||||
details: ['Cette action est irréversible.'],
|
||||
confirmLabel: 'Supprimer',
|
||||
variant: 'danger'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
this.service.delete(n.id!).subscribe(() => this.load());
|
||||
});
|
||||
}
|
||||
|
||||
back(): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId]);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
import { PlaythroughService } from '../../../services/playthrough.service';
|
||||
import { SessionService } from '../../../services/session.service';
|
||||
import { LayoutService } from '../../../services/layout.service';
|
||||
@@ -56,6 +57,7 @@ export class PlaythroughDetailComponent implements OnInit, OnDestroy {
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
private playthroughService: PlaythroughService,
|
||||
private sessionService: SessionService,
|
||||
private layoutService: LayoutService,
|
||||
@@ -79,7 +81,7 @@ export class PlaythroughDetailComponent implements OnInit, OnDestroy {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService),
|
||||
playthrough: this.playthroughService.getById(this.playthroughId),
|
||||
sessions: this.sessionService.getSessions(this.playthroughId).pipe(catchError(() => of([] as Session[]))),
|
||||
characters: this.characterService.getByPlaythrough(this.playthroughId).pipe(catchError(() => of([] as Character[]))),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
import { PlaythroughService } from '../../../services/playthrough.service';
|
||||
import { LayoutService } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
@@ -38,6 +39,7 @@ export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy {
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
private playthroughService: PlaythroughService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
@@ -59,7 +61,7 @@ export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService),
|
||||
playthrough: this.playthroughService.getById(this.playthroughId)
|
||||
}).subscribe(({ campaign, allCampaigns, treeData, playthrough }) => {
|
||||
this.playthrough = playthrough;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
import { LayoutService } from '../../../services/layout.service';
|
||||
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||
@@ -42,6 +43,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
private layoutService: LayoutService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
@@ -61,7 +63,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, this.npcService, this.randomTableService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||
const currentChapter = (treeData.chaptersByArc[this.arcId] ?? []).find(c => c.id === this.chapterId);
|
||||
this.chapterName = currentChapter?.name ?? '';
|
||||
|
||||
@@ -202,7 +202,19 @@
|
||||
<input id="scene-edit-combat-difficulty" type="text" formControlName="combatDifficulty" placeholder="Ex: Moyenne, 3 gobelins niveau 2" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="scene-edit-enemies">Ennemis et créatures</label>
|
||||
<label>Ennemis du bestiaire</label>
|
||||
<app-enemy-link-picker
|
||||
[value]="enemyIds"
|
||||
[availableEnemies]="availableEnemies"
|
||||
[campaignId]="campaignId"
|
||||
(valueChange)="enemyIds = $event">
|
||||
</app-enemy-link-picker>
|
||||
<small class="field-hint">
|
||||
Référencez des fiches de votre bestiaire — cliquez sur un chip pour ouvrir la fiche.
|
||||
</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="scene-edit-enemies">Ennemis et créatures (texte libre)</label>
|
||||
<textarea
|
||||
id="scene-edit-enemies"
|
||||
formControlName="enemies"
|
||||
|
||||
@@ -9,14 +9,17 @@ import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
import { PageService } from '../../../services/page.service';
|
||||
import { LayoutService } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
import { Scene, SceneBranch, Room } from '../../../services/campaign.model';
|
||||
import { Page } from '../../../services/page.model';
|
||||
import { Enemy } from '../../../services/enemy.model';
|
||||
import { loadCampaignTreeData, buildCampaignSidebarConfig } 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 { EnemyLinkPickerComponent } from '../../../shared/enemy-link-picker/enemy-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';
|
||||
@@ -30,7 +33,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-scene-edit',
|
||||
imports: [ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent, RoomsEditorComponent],
|
||||
imports: [ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, EnemyLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent, RoomsEditorComponent],
|
||||
templateUrl: './scene-edit.component.html',
|
||||
styleUrls: ['./scene-edit.component.scss']
|
||||
})
|
||||
@@ -60,6 +63,9 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
availablePages: Page[] = [];
|
||||
loreId: string | null = null;
|
||||
relatedPageIds: string[] = [];
|
||||
/** Bestiaire de la campagne + fiches liées à la rencontre. */
|
||||
availableEnemies: Enemy[] = [];
|
||||
enemyIds: string[] = [];
|
||||
illustrationImageIds: string[] = [];
|
||||
mapImageIds: string[] = [];
|
||||
|
||||
@@ -81,6 +87,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
@@ -133,7 +140,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, this.npcService, this.randomTableService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
@@ -146,6 +153,8 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
this.loreId = loreId;
|
||||
this.availablePages = pages;
|
||||
this.relatedPageIds = [...(scene.relatedPageIds ?? [])];
|
||||
this.availableEnemies = treeData.enemies ?? [];
|
||||
this.enemyIds = [...(scene.enemyIds ?? [])];
|
||||
this.selectedIcon = scene.icon ?? null;
|
||||
this.illustrationImageIds = [...(scene.illustrationImageIds ?? [])];
|
||||
this.mapImageIds = [...(scene.mapImageIds ?? [])];
|
||||
@@ -184,6 +193,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
choicesConsequences: this.form.value.choicesConsequences,
|
||||
combatDifficulty: this.form.value.combatDifficulty,
|
||||
enemies: this.form.value.enemies,
|
||||
enemyIds: this.enemyIds,
|
||||
relatedPageIds: this.relatedPageIds,
|
||||
illustrationImageIds: this.illustrationImageIds,
|
||||
mapImageIds: this.mapImageIds,
|
||||
|
||||
@@ -81,17 +81,29 @@
|
||||
</section>
|
||||
}
|
||||
<!-- Combat ou rencontre -->
|
||||
@if (scene.combatDifficulty?.trim() || scene.enemies?.trim()) {
|
||||
@if (scene.combatDifficulty?.trim() || scene.enemies?.trim() || linkedEnemies.length > 0) {
|
||||
@if (scene.combatDifficulty?.trim()) {
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">⚔️</span> Difficulté estimée</h2>
|
||||
<p class="view-section-body">{{ scene.combatDifficulty }}</p>
|
||||
</section>
|
||||
}
|
||||
@if (scene.enemies?.trim()) {
|
||||
@if (scene.enemies?.trim() || linkedEnemies.length > 0) {
|
||||
<section class="view-section">
|
||||
<h2 class="view-section-title"><span class="view-section-icon">🐲</span> Ennemis et créatures</h2>
|
||||
<p class="view-section-body">{{ scene.enemies }}</p>
|
||||
@if (linkedEnemies.length > 0) {
|
||||
<div class="view-chips">
|
||||
@for (e of linkedEnemies; track e.id) {
|
||||
<a class="view-chip"
|
||||
[routerLink]="['/campaigns', campaignId, 'enemies', e.id]">
|
||||
{{ enemyLabel(e) }}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (scene.enemies?.trim()) {
|
||||
<p class="view-section-body">{{ scene.enemies }}</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,13 @@ import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
import { PageService } from '../../../services/page.service';
|
||||
import { LayoutService } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
import { Scene } from '../../../services/campaign.model';
|
||||
import { Page } from '../../../services/page.model';
|
||||
import { Enemy } from '../../../services/enemy.model';
|
||||
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||
@@ -41,6 +43,8 @@ export class SceneViewComponent implements OnInit, OnDestroy {
|
||||
|
||||
loreId: string | null = null;
|
||||
availablePages: Page[] = [];
|
||||
/** Bestiaire de la campagne — résout les enemyIds de la scène en fiches. */
|
||||
availableEnemies: Enemy[] = [];
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
@@ -49,6 +53,7 @@ export class SceneViewComponent implements OnInit, OnDestroy {
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
@@ -79,7 +84,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, this.npcService, this.randomTableService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
@@ -90,6 +95,7 @@ export class SceneViewComponent implements OnInit, OnDestroy {
|
||||
this.scene = scene;
|
||||
this.loreId = loreId;
|
||||
this.availablePages = pages;
|
||||
this.availableEnemies = treeData.enemies ?? [];
|
||||
this.pageTitleService.set(scene.name);
|
||||
|
||||
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
|
||||
@@ -100,6 +106,19 @@ export class SceneViewComponent implements OnInit, OnDestroy {
|
||||
return this.availablePages.find(p => p.id === pageId)?.title ?? '(page supprimée)';
|
||||
}
|
||||
|
||||
/** Fiches du bestiaire liées à la rencontre (IDs orphelins ignorés). */
|
||||
get linkedEnemies(): Enemy[] {
|
||||
return (this.scene?.enemyIds ?? [])
|
||||
.map(id => this.availableEnemies.find(e => e.id === id))
|
||||
.filter((e): e is Enemy => !!e);
|
||||
}
|
||||
|
||||
/** Libellé d'un chip ennemi : nom + niveau s'il est renseigné. */
|
||||
enemyLabel(enemy: Enemy): string {
|
||||
const level = enemy.level?.trim();
|
||||
return level ? `${enemy.name} (${level})` : enemy.name;
|
||||
}
|
||||
|
||||
/** Résout le nom d'une pièce cible (pour afficher les sorties inter-pièces). */
|
||||
roomNameById(scene: Scene | null, roomId: string): string {
|
||||
return scene?.rooms?.find(r => r.id === roomId)?.name ?? '(pièce supprimée)';
|
||||
|
||||
@@ -159,6 +159,14 @@
|
||||
[suggestions]="npcFieldSuggestions"
|
||||
(fieldsChange)="npcTemplate = $event">
|
||||
</app-template-fields-editor>
|
||||
|
||||
<app-template-fields-editor
|
||||
label="Champs de la fiche Ennemi"
|
||||
hint="Affiches lors de la creation/edition d'un ennemi (bestiaire). Nom, niveau, dossier, portrait et bandeau sont automatiques."
|
||||
[fields]="enemyTemplate"
|
||||
[suggestions]="enemyFieldSuggestions"
|
||||
(fieldsChange)="enemyTemplate = $event">
|
||||
</app-template-fields-editor>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
|
||||
@@ -39,6 +39,9 @@ const CHARACTER_FIELD_SUGGESTIONS = ['Histoire', 'Personnalite', 'Apparence', 'N
|
||||
/** Suggestions de champs pour la fiche PNJ — focus sur les besoins MJ. */
|
||||
const NPC_FIELD_SUGGESTIONS = ['Motivation', 'Apparence', 'Faction', 'Notes MJ'];
|
||||
|
||||
/** Suggestions de champs pour la fiche ENNEMI — stats de combat. */
|
||||
const ENEMY_FIELD_SUGGESTIONS = ['Stats', 'Attaques', 'Capacités', 'Faiblesses', 'Butin', 'Tactique'];
|
||||
|
||||
@Component({
|
||||
selector: 'app-game-system-edit',
|
||||
imports: [FormsModule, LucideAngularModule, TemplateFieldsEditorComponent],
|
||||
@@ -82,10 +85,12 @@ export class GameSystemEditComponent implements OnInit {
|
||||
sections: RuleSection[] = [];
|
||||
characterTemplate: TemplateField[] = [];
|
||||
npcTemplate: TemplateField[] = [];
|
||||
enemyTemplate: TemplateField[] = [];
|
||||
|
||||
readonly suggestedSections = SUGGESTED_SECTIONS;
|
||||
readonly characterFieldSuggestions = CHARACTER_FIELD_SUGGESTIONS;
|
||||
readonly npcFieldSuggestions = NPC_FIELD_SUGGESTIONS;
|
||||
readonly enemyFieldSuggestions = ENEMY_FIELD_SUGGESTIONS;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
@@ -104,6 +109,7 @@ export class GameSystemEditComponent implements OnInit {
|
||||
this.sections = this.parseMarkdown(gs.rulesMarkdown ?? '');
|
||||
this.characterTemplate = gs.characterTemplate ? [...gs.characterTemplate] : [];
|
||||
this.npcTemplate = gs.npcTemplate ? [...gs.npcTemplate] : [];
|
||||
this.enemyTemplate = gs.enemyTemplate ? [...gs.enemyTemplate] : [];
|
||||
},
|
||||
error: () => this.back()
|
||||
});
|
||||
@@ -243,6 +249,7 @@ export class GameSystemEditComponent implements OnInit {
|
||||
rulesMarkdown: this.serializeMarkdown(),
|
||||
characterTemplate: this.characterTemplate,
|
||||
npcTemplate: this.npcTemplate,
|
||||
enemyTemplate: this.enemyTemplate,
|
||||
isPublic: false
|
||||
};
|
||||
const req = this.id
|
||||
@@ -260,7 +267,9 @@ export class GameSystemEditComponent implements OnInit {
|
||||
|
||||
/** Validation cote front : nom vide ou doublons (case-insensitive). */
|
||||
private hasInvalidTemplateFields(): boolean {
|
||||
return this.hasInvalidList(this.characterTemplate) || this.hasInvalidList(this.npcTemplate);
|
||||
return this.hasInvalidList(this.characterTemplate)
|
||||
|| this.hasInvalidList(this.npcTemplate)
|
||||
|| this.hasInvalidList(this.enemyTemplate);
|
||||
}
|
||||
|
||||
private hasInvalidList(fields: TemplateField[]): boolean {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CampaignService } from './campaign.service';
|
||||
import { CharacterService } from './character.service';
|
||||
import { NpcService } from './npc.service';
|
||||
import { RandomTableService } from './random-table.service';
|
||||
import { EnemyService } from './enemy.service';
|
||||
import { LayoutService } from './layout.service';
|
||||
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../campaigns/campaign-tree.helper';
|
||||
|
||||
@@ -28,6 +29,7 @@ export class CampaignSidebarService {
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
private layoutService: LayoutService
|
||||
) {}
|
||||
|
||||
@@ -45,7 +47,8 @@ export class CampaignSidebarService {
|
||||
campaignId,
|
||||
this.characterService,
|
||||
this.npcService,
|
||||
this.randomTableService
|
||||
this.randomTableService,
|
||||
this.enemyService
|
||||
)
|
||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, campaignId));
|
||||
|
||||
@@ -202,6 +202,8 @@ export interface Room {
|
||||
name: string;
|
||||
description?: string;
|
||||
enemies?: string;
|
||||
/** IDs des fiches du bestiaire présentes dans la pièce (weak refs). */
|
||||
enemyIds?: string[];
|
||||
loot?: string;
|
||||
traps?: string;
|
||||
gmNotes?: string;
|
||||
@@ -231,6 +233,9 @@ export interface Scene {
|
||||
combatDifficulty?: string;
|
||||
enemies?: string;
|
||||
|
||||
/** IDs des fiches du bestiaire engagées dans la rencontre (weak refs). */
|
||||
enemyIds?: string[];
|
||||
|
||||
relatedPageIds?: string[];
|
||||
illustrationImageIds?: string[];
|
||||
mapImageIds?: string[];
|
||||
@@ -258,6 +263,9 @@ export interface SceneCreate {
|
||||
combatDifficulty?: string;
|
||||
enemies?: string;
|
||||
|
||||
/** IDs des fiches du bestiaire engagées dans la rencontre (weak refs). */
|
||||
enemyIds?: string[];
|
||||
|
||||
relatedPageIds?: string[];
|
||||
illustrationImageIds?: string[];
|
||||
mapImageIds?: string[];
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Character, CharacterCreate } from './character.model';
|
||||
|
||||
/** Résultat de recherche d'un PJ, enrichi du campaignId pour la navigation. */
|
||||
export interface CharacterSearchResult {
|
||||
id: string;
|
||||
name: string;
|
||||
playthroughId: string;
|
||||
campaignId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service HTTP pour les fiches de personnages (PJ) d'une campagne.
|
||||
*/
|
||||
@@ -31,4 +39,10 @@ export class CharacterService {
|
||||
delete(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
|
||||
search(q: string): Observable<CharacterSearchResult[]> {
|
||||
const params = new HttpParams().set('q', q);
|
||||
return this.http.get<CharacterSearchResult[]>(`${this.apiUrl}/search`, { params });
|
||||
}
|
||||
}
|
||||
|
||||
32
web/src/app/services/enemy.model.ts
Normal file
32
web/src/app/services/enemy.model.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Fiche d'ennemi (monstre/créature) d'une campagne — le bestiaire du MJ.
|
||||
* Même structure de templating que Npc : champs pilotés par le template
|
||||
* ENNEMI du GameSystem, + champs universels niveau/dossier.
|
||||
*/
|
||||
export interface Enemy {
|
||||
id?: string;
|
||||
name: string;
|
||||
/** Niveau / FP / dangerosité — texte libre (« 5 », « FP 8 », « Boss »). */
|
||||
level?: string | null;
|
||||
/** Dossier de classement (« Démons », « Humanoïdes »…). Vide = non classé. */
|
||||
folder?: string | null;
|
||||
portraitImageId?: string | null;
|
||||
headerImageId?: string | null;
|
||||
values?: Record<string, string>;
|
||||
imageValues?: Record<string, string[]>;
|
||||
keyValueValues?: Record<string, Record<string, string>>;
|
||||
campaignId: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface EnemyCreate {
|
||||
name: string;
|
||||
level?: string | null;
|
||||
folder?: string | null;
|
||||
portraitImageId?: string | null;
|
||||
headerImageId?: string | null;
|
||||
values?: Record<string, string>;
|
||||
imageValues?: Record<string, string[]>;
|
||||
keyValueValues?: Record<string, Record<string, string>>;
|
||||
campaignId: string;
|
||||
}
|
||||
39
web/src/app/services/enemy.service.ts
Normal file
39
web/src/app/services/enemy.service.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Enemy, EnemyCreate } from './enemy.model';
|
||||
|
||||
/**
|
||||
* Service HTTP des fiches d'ennemis (bestiaire de campagne).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class EnemyService {
|
||||
private apiUrl = '/api/enemies';
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
getByCampaign(campaignId: string): Observable<Enemy[]> {
|
||||
return this.http.get<Enemy[]>(`${this.apiUrl}/campaign/${campaignId}`);
|
||||
}
|
||||
|
||||
getById(id: string): Observable<Enemy> {
|
||||
return this.http.get<Enemy>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
create(payload: EnemyCreate): Observable<Enemy> {
|
||||
return this.http.post<Enemy>(this.apiUrl, payload);
|
||||
}
|
||||
|
||||
update(id: string, payload: Enemy): Observable<Enemy> {
|
||||
return this.http.put<Enemy>(`${this.apiUrl}/${id}`, payload);
|
||||
}
|
||||
|
||||
delete(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
|
||||
search(q: string): Observable<Enemy[]> {
|
||||
return this.http.get<Enemy[]>(`${this.apiUrl}/search`, { params: { q } });
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export interface GameSystem {
|
||||
rulesMarkdown?: string | null;
|
||||
characterTemplate?: TemplateField[];
|
||||
npcTemplate?: TemplateField[];
|
||||
enemyTemplate?: TemplateField[];
|
||||
author?: string | null;
|
||||
isPublic?: boolean;
|
||||
}
|
||||
@@ -50,6 +51,7 @@ export interface GameSystemCreate {
|
||||
rulesMarkdown?: string | null;
|
||||
characterTemplate?: TemplateField[];
|
||||
npcTemplate?: TemplateField[];
|
||||
enemyTemplate?: TemplateField[];
|
||||
author?: string | null;
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,11 @@ export class ItemCatalogService {
|
||||
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
|
||||
search(q: string): Observable<ItemCatalog[]> {
|
||||
return this.http.get<ItemCatalog[]>(`${this.apiUrl}/search`, { params: { q } });
|
||||
}
|
||||
|
||||
/** Génère une PROPOSITION de catalogue via l'IA (non persistée) à préremplir. */
|
||||
generate(campaignId: string, description: string): Observable<ItemCatalog> {
|
||||
return this.http.post<ItemCatalog>(`${this.apiUrl}/generate`, { campaignId, description });
|
||||
|
||||
@@ -15,10 +15,37 @@ export interface NotebookAction {
|
||||
type: 'npc' | 'scene' | 'chapter' | 'arc' | 'table';
|
||||
name: string;
|
||||
description?: string;
|
||||
/** Legacy (anciens messages) : déroulé d'une scène → repli sur playerNarration. */
|
||||
content?: string;
|
||||
arcType?: 'LINEAR' | 'HUB';
|
||||
diceFormula?: string;
|
||||
entries?: NotebookActionEntry[];
|
||||
|
||||
// --- PNJ : valeurs des champs TEXT de la fiche (clés = template du système). ---
|
||||
values?: Record<string, string>;
|
||||
|
||||
// --- Scène : champs narratifs enrichis (miroir de SceneCreate). ---
|
||||
location?: string;
|
||||
timing?: string;
|
||||
atmosphere?: string;
|
||||
playerNarration?: string;
|
||||
gmSecretNotes?: string;
|
||||
choicesConsequences?: string;
|
||||
combatDifficulty?: string;
|
||||
enemies?: string;
|
||||
|
||||
// --- Chapitre. ---
|
||||
playerObjectives?: string;
|
||||
narrativeStakes?: string;
|
||||
|
||||
// --- Chapitre + Arc. ---
|
||||
gmNotes?: string;
|
||||
|
||||
// --- Arc. ---
|
||||
themes?: string;
|
||||
stakes?: string;
|
||||
rewards?: string;
|
||||
resolution?: string;
|
||||
}
|
||||
|
||||
const ACTION_RE = /```loremind-action\s*([\s\S]*?)```/g;
|
||||
|
||||
@@ -36,4 +36,9 @@ export class NpcService {
|
||||
delete(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
|
||||
search(q: string): Observable<Npc[]> {
|
||||
return this.http.get<Npc[]>(`${this.apiUrl}/search`, { params: { q } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,11 @@ export class RandomTableService {
|
||||
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
|
||||
search(q: string): Observable<RandomTable[]> {
|
||||
return this.http.get<RandomTable[]>(`${this.apiUrl}/search`, { params: { q } });
|
||||
}
|
||||
|
||||
/** Génère une PROPOSITION de table via l'IA (non persistée) à préremplir dans le formulaire. */
|
||||
generate(campaignId: string, description: string, diceFormula: string): Observable<RandomTable> {
|
||||
return this.http.post<RandomTable>(`${this.apiUrl}/generate`, { campaignId, description, diceFormula });
|
||||
|
||||
@@ -5,6 +5,7 @@ import { catchError, of } from 'rxjs';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
import { NpcService } from '../../services/npc.service';
|
||||
import { EnemyService } from '../../services/enemy.service';
|
||||
import { Character } from '../../services/character.model';
|
||||
import { Npc } from '../../services/npc.model';
|
||||
import { Arc, Chapter, Scene } from '../../services/campaign.model';
|
||||
@@ -70,7 +71,8 @@ export class SessionReferencePanelComponent implements OnChanges {
|
||||
constructor(
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService
|
||||
private npcService: NpcService,
|
||||
private enemyService: EnemyService
|
||||
) {}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
@@ -113,8 +115,8 @@ export class SessionReferencePanelComponent implements OnChanges {
|
||||
private ensureTreeLoaded(): void {
|
||||
if (this.treeLoaded || this.loadingTree || !this.campaignId) return;
|
||||
this.loadingTree = true;
|
||||
loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService).pipe(
|
||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [] } as CampaignTreeData))
|
||||
loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, undefined, this.enemyService).pipe(
|
||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [], enemies: [] } as CampaignTreeData))
|
||||
).subscribe(data => {
|
||||
this.treeData = data;
|
||||
this.loadingTree = false;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<div class="picker">
|
||||
|
||||
<!-- Chips des ennemis déjà liés -->
|
||||
@if (linkedEnemies.length > 0) {
|
||||
<div class="linked-chips">
|
||||
@for (e of linkedEnemies; track e.id) {
|
||||
<span class="chip">
|
||||
<a
|
||||
class="chip-label"
|
||||
[href]="enemyUrl(e.id!)"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
title="Ouvrir la fiche dans un nouvel onglet">
|
||||
<lucide-icon [img]="Skull" [size]="12"></lucide-icon>
|
||||
{{ label(e) }}
|
||||
</a>
|
||||
<button type="button" class="chip-remove" (click)="remove(e.id!)" title="Retirer le lien">
|
||||
<lucide-icon [img]="X" [size]="12"></lucide-icon>
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Input + dropdown de suggestions -->
|
||||
<div class="search-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Rechercher un ennemi du bestiaire..."
|
||||
[(ngModel)]="query"
|
||||
(focus)="dropdownOpen = true"
|
||||
(blur)="onBlur()" />
|
||||
|
||||
@if (dropdownOpen && suggestions.length > 0) {
|
||||
<ul class="suggestions">
|
||||
@for (e of suggestions; track e.id) {
|
||||
<li (mousedown)="add(e)">
|
||||
{{ label(e) }}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@if (dropdownOpen && query && suggestions.length === 0) {
|
||||
<p class="empty-hint">
|
||||
Aucun ennemi ne correspond
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,114 @@
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.linked-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: #3a1c24;
|
||||
color: #fda4af;
|
||||
border-radius: 999px;
|
||||
font-size: 0.82rem;
|
||||
overflow: hidden;
|
||||
|
||||
.chip-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover { color: #fecdd3; }
|
||||
}
|
||||
|
||||
.chip-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 1px solid #2a2a3d;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover { opacity: 1; color: #fca5a5; background: #2a2a3d; }
|
||||
}
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
color: white;
|
||||
padding: 0.65rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.88rem;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #6c63ff;
|
||||
}
|
||||
|
||||
&::placeholder { color: #6b7280; }
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
list-style: none;
|
||||
padding: 0.25rem 0;
|
||||
margin: 0;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
z-index: 10;
|
||||
|
||||
li {
|
||||
padding: 0.55rem 0.9rem;
|
||||
color: #d1d5db;
|
||||
font-size: 0.88rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover { background: #20203a; color: white; }
|
||||
}
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 0.55rem 0.9rem;
|
||||
margin: 0;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
border-radius: 6px;
|
||||
color: #6b7280;
|
||||
font-size: 0.82rem;
|
||||
font-style: italic;
|
||||
z-index: 10;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Component, Input, Output, EventEmitter } from '@angular/core';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, X, Skull } from 'lucide-angular';
|
||||
import { Enemy } from '../../services/enemy.model';
|
||||
|
||||
/**
|
||||
* Composant pour lier une scène (ou toute entité) à des fiches du bestiaire
|
||||
* par leurs IDs. Pendant côté ennemis du LoreLinkPickerComponent.
|
||||
*
|
||||
* Usage :
|
||||
* <app-enemy-link-picker
|
||||
* [value]="enemyIds"
|
||||
* [availableEnemies]="enemies"
|
||||
* [campaignId]="campaignId"
|
||||
* (valueChange)="enemyIds = $event"></app-enemy-link-picker>
|
||||
*
|
||||
* Design :
|
||||
* - Fiches liées = chips cliquables (clic → fiche ennemi en nouvel onglet)
|
||||
* - Input de recherche avec dropdown de suggestions filtrées (max 8 résultats)
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-enemy-link-picker',
|
||||
imports: [FormsModule, LucideAngularModule],
|
||||
templateUrl: './enemy-link-picker.component.html',
|
||||
styleUrls: ['./enemy-link-picker.component.scss']
|
||||
})
|
||||
export class EnemyLinkPickerComponent {
|
||||
readonly X = X;
|
||||
readonly Skull = Skull;
|
||||
|
||||
/** IDs des ennemis actuellement liés (contrôlés par le parent). */
|
||||
@Input() value: string[] = [];
|
||||
/** Bestiaire de la campagne dans lequel on peut piocher. */
|
||||
@Input() availableEnemies: Enemy[] = [];
|
||||
/** ID de la campagne, pour construire les URLs des chips cliquables. */
|
||||
@Input() campaignId = '';
|
||||
|
||||
@Output() valueChange = new EventEmitter<string[]>();
|
||||
|
||||
/** Texte de recherche courant. */
|
||||
query = '';
|
||||
/** true tant que l'input a le focus (pour afficher le dropdown). */
|
||||
dropdownOpen = false;
|
||||
|
||||
/** Ennemis actuellement liés (résolus en objets complets pour affichage). */
|
||||
get linkedEnemies(): Enemy[] {
|
||||
return this.value
|
||||
.map(id => this.availableEnemies.find(e => e.id === id))
|
||||
.filter((e): e is Enemy => !!e);
|
||||
}
|
||||
|
||||
/** Ennemis proposables dans le dropdown — filtrés par query, exclut les déjà liés. */
|
||||
get suggestions(): Enemy[] {
|
||||
const q = this.query.trim().toLowerCase();
|
||||
return this.availableEnemies
|
||||
.filter(e => !this.value.includes(e.id!))
|
||||
.filter(e => q === '' || e.name.toLowerCase().includes(q))
|
||||
.slice(0, 8);
|
||||
}
|
||||
|
||||
/** Libellé d'une suggestion / chip : nom + niveau s'il est renseigné. */
|
||||
label(enemy: Enemy): string {
|
||||
const level = enemy.level?.trim();
|
||||
return level ? `${enemy.name} (${level})` : enemy.name;
|
||||
}
|
||||
|
||||
/** Ajoute un ennemi aux liens. */
|
||||
add(enemy: Enemy): void {
|
||||
if (!enemy.id || this.value.includes(enemy.id)) return;
|
||||
this.valueChange.emit([...this.value, enemy.id]);
|
||||
this.query = '';
|
||||
}
|
||||
|
||||
/** Retire un ennemi des liens. */
|
||||
remove(enemyId: string): void {
|
||||
this.valueChange.emit(this.value.filter(id => id !== enemyId));
|
||||
}
|
||||
|
||||
/**
|
||||
* URL vers la fiche ennemi — utilisée par un <a target="_blank"> dans le
|
||||
* template : on consulte la fiche sans perdre la scène en cours d'édition.
|
||||
*/
|
||||
enemyUrl(enemyId: string): string {
|
||||
return `/campaigns/${this.campaignId}/enemies/${enemyId}`;
|
||||
}
|
||||
|
||||
/** Retarde la fermeture du dropdown pour laisser le temps au clic de se propager. */
|
||||
onBlur(): void {
|
||||
setTimeout(() => { this.dropdownOpen = false; }, 150);
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,21 @@ import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { BehaviorSubject, Subject, forkJoin, of } from 'rxjs';
|
||||
import { catchError, debounceTime, distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Search, BookOpen, Folder, Users, FileText, Scroll } from 'lucide-angular';
|
||||
import { LucideAngularModule, Search, BookOpen, Folder, Users, FileText, Scroll, Drama, User, Dices, Package, Skull } from 'lucide-angular';
|
||||
import { GlobalSearchService } from '../../services/global-search.service';
|
||||
import { LoreService } from '../../services/lore.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { TemplateService } from '../../services/template.service';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { NpcService } from '../../services/npc.service';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
import { RandomTableService } from '../../services/random-table.service';
|
||||
import { ItemCatalogService } from '../../services/item-catalog.service';
|
||||
import { EnemyService } from '../../services/enemy.service';
|
||||
|
||||
type ResultKind = 'lore' | 'node' | 'template' | 'page' | 'campaign';
|
||||
type ResultKind =
|
||||
| 'lore' | 'node' | 'template' | 'page' | 'campaign'
|
||||
| 'npc' | 'character' | 'random-table' | 'item-catalog' | 'enemy';
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
@@ -26,8 +33,9 @@ interface SearchResult {
|
||||
|
||||
/**
|
||||
* Command palette globale (Ctrl+K / Cmd+K).
|
||||
* Agrège 4 endpoints search (Lore, LoreNode, Template, Page) côté frontend.
|
||||
* Navigation clavier : ↑↓ ↵ Esc.
|
||||
* Agrège les endpoints search de toutes les entités nommées : lore (lores,
|
||||
* dossiers, templates, pages) ET campagne (campagnes, PNJ, PJ, ennemis, tables
|
||||
* aléatoires, catalogues d'objets). Navigation clavier : ↑↓ ↵ Esc.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-global-search',
|
||||
@@ -42,6 +50,11 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
readonly Users = Users;
|
||||
readonly FileText = FileText;
|
||||
readonly Scroll = Scroll;
|
||||
readonly Drama = Drama;
|
||||
readonly User = User;
|
||||
readonly Dices = Dices;
|
||||
readonly Package = Package;
|
||||
readonly Skull = Skull;
|
||||
|
||||
@ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>;
|
||||
|
||||
@@ -60,7 +73,12 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
private loreService: LoreService,
|
||||
private pageService: PageService,
|
||||
private templateService: TemplateService,
|
||||
private campaignService: CampaignService
|
||||
private campaignService: CampaignService,
|
||||
private npcService: NpcService,
|
||||
private characterService: CharacterService,
|
||||
private randomTableService: RandomTableService,
|
||||
private itemCatalogService: ItemCatalogService,
|
||||
private enemyService: EnemyService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -86,13 +104,18 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
this.loading = true;
|
||||
return forkJoin({
|
||||
lores: this.loreService.searchLores(trimmed).pipe(catchError(() => of([]))),
|
||||
nodes: this.loreService.searchLoreNodes(trimmed).pipe(catchError(() => of([]))),
|
||||
templates: this.templateService.search(trimmed).pipe(catchError(() => of([]))),
|
||||
pages: this.pageService.search(trimmed).pipe(catchError(() => of([]))),
|
||||
campaigns: this.campaignService.search(trimmed).pipe(catchError(() => of([])))
|
||||
lores: this.loreService.searchLores(trimmed).pipe(catchError(() => of([]))),
|
||||
nodes: this.loreService.searchLoreNodes(trimmed).pipe(catchError(() => of([]))),
|
||||
templates: this.templateService.search(trimmed).pipe(catchError(() => of([]))),
|
||||
pages: this.pageService.search(trimmed).pipe(catchError(() => of([]))),
|
||||
campaigns: this.campaignService.search(trimmed).pipe(catchError(() => of([]))),
|
||||
npcs: this.npcService.search(trimmed).pipe(catchError(() => of([]))),
|
||||
characters: this.characterService.search(trimmed).pipe(catchError(() => of([]))),
|
||||
tables: this.randomTableService.search(trimmed).pipe(catchError(() => of([]))),
|
||||
catalogs: this.itemCatalogService.search(trimmed).pipe(catchError(() => of([]))),
|
||||
enemies: this.enemyService.search(trimmed).pipe(catchError(() => of([])))
|
||||
}).pipe(
|
||||
switchMap(({ lores, nodes, templates, pages, campaigns }) => of(this.buildResults(lores, nodes, templates, pages, campaigns))),
|
||||
switchMap(r => of(this.buildResults(r))),
|
||||
catchError(() => of<SearchResult[]>([]))
|
||||
);
|
||||
}),
|
||||
@@ -111,11 +134,14 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
|
||||
/**
|
||||
* Construit la liste unifiée. Ordre d'affichage : pages d'abord (le plus recherché),
|
||||
* puis noeuds, templates, campagnes, et lores — les entités racines apparaissent en dernier.
|
||||
* puis les entités de campagne (PNJ, PJ, ennemis, tables, catalogues), les
|
||||
* noeuds/templates, et enfin les racines (campagnes, lores).
|
||||
*/
|
||||
private buildResults(
|
||||
lores: any[], nodes: any[], templates: any[], pages: any[], campaigns: any[]
|
||||
): SearchResult[] {
|
||||
private buildResults(r: {
|
||||
lores: any[]; nodes: any[]; templates: any[]; pages: any[]; campaigns: any[];
|
||||
npcs: any[]; characters: any[]; tables: any[]; catalogs: any[]; enemies: any[];
|
||||
}): SearchResult[] {
|
||||
const { lores, nodes, templates, pages, campaigns, npcs, characters, tables, catalogs, enemies } = r;
|
||||
const pageResults: SearchResult[] = pages.map(p => ({
|
||||
id: p.id,
|
||||
kind: 'page' as ResultKind,
|
||||
@@ -156,7 +182,52 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
tag: 'Campagne',
|
||||
route: ['/campaigns', c.id]
|
||||
}));
|
||||
return [...pageResults, ...nodeResults, ...templateResults, ...campaignResults, ...loreResults];
|
||||
const npcResults: SearchResult[] = npcs.map(n => ({
|
||||
id: n.id,
|
||||
kind: 'npc' as ResultKind,
|
||||
title: n.name,
|
||||
subtitle: (n.folder ?? '') as string,
|
||||
tag: 'PNJ',
|
||||
route: ['/campaigns', n.campaignId, 'npcs', n.id]
|
||||
}));
|
||||
const characterResults: SearchResult[] = characters.map(c => ({
|
||||
id: c.id,
|
||||
kind: 'character' as ResultKind,
|
||||
title: c.name,
|
||||
subtitle: '',
|
||||
tag: 'PJ',
|
||||
route: ['/campaigns', c.campaignId, 'playthroughs', c.playthroughId, 'characters', c.id]
|
||||
}));
|
||||
const tableResults: SearchResult[] = tables.map(t => ({
|
||||
id: t.id,
|
||||
kind: 'random-table' as ResultKind,
|
||||
title: t.name,
|
||||
subtitle: t.description ?? '',
|
||||
tag: 'Table aléatoire',
|
||||
route: ['/campaigns', t.campaignId, 'random-tables', t.id]
|
||||
}));
|
||||
const catalogResults: SearchResult[] = catalogs.map(c => ({
|
||||
id: c.id,
|
||||
kind: 'item-catalog' as ResultKind,
|
||||
title: c.name,
|
||||
subtitle: c.description ?? '',
|
||||
tag: 'Catalogue d\'objets',
|
||||
route: ['/campaigns', c.campaignId, 'item-catalogs', c.id]
|
||||
}));
|
||||
const enemyResults: SearchResult[] = enemies.map(e => ({
|
||||
id: e.id,
|
||||
kind: 'enemy' as ResultKind,
|
||||
title: e.name,
|
||||
subtitle: [e.level ? `Niv. ${e.level}` : '', e.folder ?? ''].filter(Boolean).join(' · '),
|
||||
tag: 'Ennemi',
|
||||
route: ['/campaigns', e.campaignId, 'enemies', e.id]
|
||||
}));
|
||||
return [
|
||||
...pageResults,
|
||||
...npcResults, ...characterResults, ...enemyResults, ...tableResults, ...catalogResults,
|
||||
...nodeResults, ...templateResults,
|
||||
...campaignResults, ...loreResults
|
||||
];
|
||||
}
|
||||
|
||||
private firstLine(text: string): string {
|
||||
@@ -204,6 +275,11 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
case 'template': return this.Users;
|
||||
case 'page': return this.FileText;
|
||||
case 'campaign': return this.Scroll;
|
||||
case 'npc': return this.Drama;
|
||||
case 'character': return this.User;
|
||||
case 'random-table': return this.Dices;
|
||||
case 'item-catalog': return this.Package;
|
||||
case 'enemy': return this.Skull;
|
||||
default: return this.FileText;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,9 +45,18 @@
|
||||
(ngModelChange)="patch(r, { description: $event })"
|
||||
placeholder="Atmosphère, ce que voient les PJ en entrant…"></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Ennemis du bestiaire</label>
|
||||
<app-enemy-link-picker
|
||||
[value]="r.enemyIds ?? []"
|
||||
[availableEnemies]="availableEnemies"
|
||||
[campaignId]="campaignId"
|
||||
(valueChange)="patch(r, { enemyIds: $event })">
|
||||
</app-enemy-link-picker>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Ennemis / créatures / boss</label>
|
||||
<label>Ennemis / créatures / boss (texte libre)</label>
|
||||
<textarea rows="3"
|
||||
[ngModel]="r.enemies"
|
||||
(ngModelChange)="patch(r, { enemies: $event })"
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, Plus, Trash2, ChevronDown, ChevronUp, GitBranch, X } from 'lucide-angular';
|
||||
import { Room, RoomBranch } from '../../services/campaign.model';
|
||||
import { Enemy } from '../../services/enemy.model';
|
||||
import { EnemyLinkPickerComponent } from '../enemy-link-picker/enemy-link-picker.component';
|
||||
|
||||
/**
|
||||
* Éditeur de pièces (Rooms) d'une Scene explorable.
|
||||
@@ -15,13 +17,17 @@ import { Room, RoomBranch } from '../../services/campaign.model';
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-rooms-editor',
|
||||
imports: [FormsModule, LucideAngularModule],
|
||||
imports: [FormsModule, LucideAngularModule, EnemyLinkPickerComponent],
|
||||
templateUrl: './rooms-editor.component.html',
|
||||
styleUrls: ['./rooms-editor.component.scss']
|
||||
})
|
||||
export class RoomsEditorComponent {
|
||||
|
||||
@Input() rooms: Room[] = [];
|
||||
/** Bestiaire de la campagne (pour référencer des fiches dans une pièce). */
|
||||
@Input() availableEnemies: Enemy[] = [];
|
||||
/** ID de la campagne (URLs des chips du picker d'ennemis). */
|
||||
@Input() campaignId = '';
|
||||
@Output() roomsChange = new EventEmitter<Room[]>();
|
||||
|
||||
readonly Plus = Plus;
|
||||
|
||||
Reference in New Issue
Block a user