Changement sur le Readme
Ajout d'une partie spécifique pour des PNJ dans la partie campagne
This commit is contained in:
@@ -301,6 +301,46 @@ export async function getPageById(
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface SeededNpc {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export async function seedNpc(
|
||||
request: APIRequestContext,
|
||||
opts: { campaignId: string; name?: string; markdownContent?: string | null },
|
||||
): Promise<SeededNpc> {
|
||||
const name = opts.name ?? `E2E NPC ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
const res = await request.post('/api/npcs', {
|
||||
data: {
|
||||
campaignId: opts.campaignId,
|
||||
name,
|
||||
markdownContent: opts.markdownContent ?? null,
|
||||
},
|
||||
});
|
||||
expect(res.ok(), `POST /api/npcs -> ${res.status()}`).toBeTruthy();
|
||||
const n = await res.json();
|
||||
return { id: n.id, name };
|
||||
}
|
||||
|
||||
export async function getNpcById(
|
||||
request: APIRequestContext,
|
||||
npcId: string,
|
||||
): Promise<{ id: string; name: string; markdownContent: string | null; campaignId: string; order: number }> {
|
||||
const res = await request.get(`/api/npcs/${npcId}`);
|
||||
expect(res.ok(), `GET /api/npcs/${npcId} -> ${res.status()}`).toBeTruthy();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getNpcsByCampaign(
|
||||
request: APIRequestContext,
|
||||
campaignId: string,
|
||||
): Promise<Array<{ id: string; name: string }>> {
|
||||
const res = await request.get(`/api/npcs/campaign/${campaignId}`);
|
||||
expect(res.ok(), `GET /api/npcs/campaign -> ${res.status()}`).toBeTruthy();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getTemplateById(
|
||||
request: APIRequestContext,
|
||||
templateId: string,
|
||||
|
||||
75
web/e2e/tests/campaign/npc-create.spec.ts
Normal file
75
web/e2e/tests/campaign/npc-create.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
seedCampaign,
|
||||
deleteCampaign,
|
||||
getNpcsByCampaign,
|
||||
type SeededCampaign,
|
||||
} from '../../fixtures/api';
|
||||
|
||||
test.describe('NPC creation', () => {
|
||||
let campaign: SeededCampaign;
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
campaign = await seedCampaign(request);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
||||
});
|
||||
|
||||
test('creates an NPC and redirects back to the campaign', async ({ page, request }) => {
|
||||
const npcName = `Borin le forgeron ${Date.now()}`;
|
||||
const markdown = '# Borin\n\n**Faction :** Clan Feuillefer\n\nNain barbu au regard perçant.';
|
||||
|
||||
await page.goto(`/campaigns/${campaign.id}/npcs/create`);
|
||||
await expect(page.getByRole('heading', { name: /Nouveau PNJ/i })).toBeVisible();
|
||||
|
||||
await page.getByLabel(/Nom du PNJ/i).fill(npcName);
|
||||
await page.getByLabel(/Fiche \(markdown\)/i).fill(markdown);
|
||||
|
||||
await page.getByRole('button', { name: /^Créer$/i }).click();
|
||||
|
||||
// Retour à la page campagne après création
|
||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
|
||||
|
||||
// Persistance vérifiée via API
|
||||
const npcs = await getNpcsByCampaign(request, campaign.id);
|
||||
const created = npcs.find((n) => n.name === npcName);
|
||||
expect(created).toBeDefined();
|
||||
});
|
||||
|
||||
test('submit is disabled when name is empty', async ({ page }) => {
|
||||
await page.goto(`/campaigns/${campaign.id}/npcs/create`);
|
||||
|
||||
const submit = page.getByRole('button', { name: /^Créer$/i });
|
||||
await expect(submit).toBeDisabled();
|
||||
|
||||
await page.getByLabel(/Nom du PNJ/i).fill('Elara');
|
||||
await expect(submit).toBeEnabled();
|
||||
|
||||
await page.getByLabel(/Nom du PNJ/i).fill(' ');
|
||||
await expect(submit).toBeDisabled();
|
||||
});
|
||||
|
||||
test('NPC appears in the sidebar PNJ branch', async ({ page, request }) => {
|
||||
const npcName = `Sidebar test ${Date.now()}`;
|
||||
|
||||
await page.goto(`/campaigns/${campaign.id}/npcs/create`);
|
||||
await page.getByLabel(/Nom du PNJ/i).fill(npcName);
|
||||
await page.getByRole('button', { name: /^Créer$/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
|
||||
|
||||
// Le nœud "PNJ" doit apparaître dans la sidebar avec le nouveau PNJ.
|
||||
// On clique sur le nœud PNJ pour le déplier au cas où il serait fermé,
|
||||
// puis on vérifie que le PNJ est listé.
|
||||
const pnjNode = page.getByRole('button', { name: /^PNJ\b/ }).or(
|
||||
page.locator('.tree-item', { hasText: 'PNJ' }).first(),
|
||||
);
|
||||
await expect(pnjNode.first()).toBeVisible();
|
||||
|
||||
// Vérification fallback via API : la liste contient bien le PNJ créé.
|
||||
const npcs = await getNpcsByCampaign(request, campaign.id);
|
||||
expect(npcs.map((n) => n.name)).toContain(npcName);
|
||||
});
|
||||
});
|
||||
69
web/e2e/tests/campaign/npc-edit.spec.ts
Normal file
69
web/e2e/tests/campaign/npc-edit.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
seedCampaign,
|
||||
seedNpc,
|
||||
deleteCampaign,
|
||||
getNpcById,
|
||||
type SeededCampaign,
|
||||
type SeededNpc,
|
||||
} from '../../fixtures/api';
|
||||
|
||||
test.describe('NPC edit', () => {
|
||||
let campaign: SeededCampaign;
|
||||
let npc: SeededNpc;
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
campaign = await seedCampaign(request);
|
||||
npc = await seedNpc(request, {
|
||||
campaignId: campaign.id,
|
||||
markdownContent: '# Initial\n\nFiche de départ.',
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
||||
});
|
||||
|
||||
test('edits name + markdown content and persists via API', async ({ page, request }) => {
|
||||
const newName = `${npc.name} (renommé)`;
|
||||
const newMarkdown = '# Borin réécrit\n\n**Statut :** Disparu\n\nDes traces dans la neige...';
|
||||
|
||||
await page.goto(`/campaigns/${campaign.id}/npcs/${npc.id}/edit`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Éditer le PNJ/i })).toBeVisible();
|
||||
await expect(page.getByLabel(/Nom du PNJ/i)).toHaveValue(npc.name);
|
||||
|
||||
await page.getByLabel(/Nom du PNJ/i).fill(newName);
|
||||
await page.getByLabel(/Fiche \(markdown\)/i).fill(newMarkdown);
|
||||
|
||||
await page.getByRole('button', { name: /^Enregistrer$/i }).click();
|
||||
|
||||
// Retour à la campagne après save
|
||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
|
||||
|
||||
const persisted = await getNpcById(request, npc.id);
|
||||
expect(persisted.name).toBe(newName);
|
||||
expect(persisted.markdownContent).toBe(newMarkdown);
|
||||
});
|
||||
|
||||
test('save button is disabled when name is cleared', async ({ page }) => {
|
||||
await page.goto(`/campaigns/${campaign.id}/npcs/${npc.id}/edit`);
|
||||
|
||||
const nameField = page.getByLabel(/Nom du PNJ/i);
|
||||
const saveBtn = page.getByRole('button', { name: /^Enregistrer$/i });
|
||||
|
||||
await expect(saveBtn).toBeEnabled();
|
||||
await nameField.fill('');
|
||||
await expect(saveBtn).toBeDisabled();
|
||||
await nameField.fill('OK');
|
||||
await expect(saveBtn).toBeEnabled();
|
||||
});
|
||||
|
||||
test('Assistant IA button is visible in edit mode', async ({ page }) => {
|
||||
// Vérifie l'intégration drawer chat IA — symétrique aux PJ.
|
||||
// Note : le drawer lui-même nécessite le Brain Python en route, donc
|
||||
// on ne teste que la présence du bouton trigger.
|
||||
await page.goto(`/campaigns/${campaign.id}/npcs/${npc.id}/edit`);
|
||||
await expect(page.getByRole('button', { name: /Assistant IA/i })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -15,19 +15,21 @@ export const routes: Routes = [
|
||||
{ path: 'lore/:loreId/pages/:pageId', loadComponent: () => import('./lore/page-view/page-view.component').then(m => m.PageViewComponent) },
|
||||
{ path: 'lore/:loreId/pages/:pageId/edit', loadComponent: () => import('./lore/page-edit/page-edit.component').then(m => m.PageEditComponent) },
|
||||
{ path: 'campaigns', loadComponent: () => import('./campaigns/campaigns.component').then(m => m.CampaignsComponent) },
|
||||
{ path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) },
|
||||
{ path: 'campaigns/:campaignId/characters/create', loadComponent: () => import('./campaigns/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/create', loadComponent: () => import('./campaigns/arc-create/arc-create.component').then(m => m.ArcCreateComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId', loadComponent: () => import('./campaigns/arc-view/arc-view.component').then(m => m.ArcViewComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/edit', loadComponent: () => import('./campaigns/arc-edit/arc-edit.component').then(m => m.ArcEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/create', loadComponent: () => import('./campaigns/chapter-create/chapter-create.component').then(m => m.ChapterCreateComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId', loadComponent: () => import('./campaigns/chapter-view/chapter-view.component').then(m => m.ChapterViewComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/graph', loadComponent: () => import('./campaigns/chapter-graph/chapter-graph.component').then(m => m.ChapterGraphComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/edit', loadComponent: () => import('./campaigns/chapter-edit/chapter-edit.component').then(m => m.ChapterEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/create', loadComponent: () => import('./campaigns/scene-create/scene-create.component').then(m => m.SceneCreateComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId', loadComponent: () => import('./campaigns/scene-view/scene-view.component').then(m => m.SceneViewComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId/edit', loadComponent: () => import('./campaigns/scene-edit/scene-edit.component').then(m => m.SceneEditComponent) },
|
||||
{ path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) },
|
||||
{ path: 'campaigns/:campaignId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/npcs/create', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/npcs/:npcId/edit', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/create', loadComponent: () => import('./campaigns/arc/arc-create/arc-create.component').then(m => m.ArcCreateComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId', loadComponent: () => import('./campaigns/arc/arc-view/arc-view.component').then(m => m.ArcViewComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/edit', loadComponent: () => import('./campaigns/arc/arc-edit/arc-edit.component').then(m => m.ArcEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/create', loadComponent: () => import('./campaigns/chapter/chapter-create/chapter-create.component').then(m => m.ChapterCreateComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId', loadComponent: () => import('./campaigns/chapter/chapter-view/chapter-view.component').then(m => m.ChapterViewComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/graph', loadComponent: () => import('./campaigns/chapter/chapter-graph/chapter-graph.component').then(m => m.ChapterGraphComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/edit', loadComponent: () => import('./campaigns/chapter/chapter-edit/chapter-edit.component').then(m => m.ChapterEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/create', loadComponent: () => import('./campaigns/scene/scene-create/scene-create.component').then(m => m.SceneCreateComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId', loadComponent: () => import('./campaigns/scene/scene-view/scene-view.component').then(m => m.SceneViewComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId/edit', loadComponent: () => import('./campaigns/scene/scene-edit/scene-edit.component').then(m => m.SceneEditComponent) },
|
||||
{ path: 'game-systems', loadComponent: () => import('./game-systems/game-systems.component').then(m => m.GameSystemsComponent) },
|
||||
{ path: 'game-systems/create', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) },
|
||||
{ path: 'game-systems/:id/edit', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) },
|
||||
|
||||
@@ -4,13 +4,14 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angula
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule, BookOpen } from 'lucide-angular';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { Campaign } from '../../services/campaign.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||
import { Campaign } from '../../../services/campaign.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||
|
||||
/**
|
||||
* Écran de création d'un nouvel Arc narratif (contexte Campagne).
|
||||
@@ -39,6 +40,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private layoutService: LayoutService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
@@ -56,7 +58,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
|
||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||
this.existingArcCount = treeData.arcs.length;
|
||||
|
||||
@@ -5,19 +5,20 @@ import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { Campaign, Arc } from '../../services/campaign.model';
|
||||
import { Page } from '../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
|
||||
import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
||||
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { PageService } from '../../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
import { Campaign, Arc } from '../../../services/campaign.model';
|
||||
import { Page } from '../../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
|
||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||
|
||||
/**
|
||||
* Écran de détail/modification d'un Arc.
|
||||
@@ -74,6 +75,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
@@ -111,7 +113,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
arc: this.campaignService.getArcById(this.arcId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
@@ -4,16 +4,17 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
|
||||
import { resolveCampaignIcon } from '../campaign-icons';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { Campaign, Arc } from '../../services/campaign.model';
|
||||
import { Page } from '../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
||||
import { resolveCampaignIcon } from '../../campaign-icons';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { PageService } from '../../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
import { Campaign, Arc } from '../../../services/campaign.model';
|
||||
import { Page } from '../../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||
|
||||
/**
|
||||
* Écran de consultation d'un Arc narratif (lecture seule).
|
||||
@@ -46,6 +47,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
@@ -68,7 +70,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
arc: this.campaignService.getArcById(this.arcId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
@@ -2,9 +2,11 @@ import { Observable, forkJoin, of } from 'rxjs';
|
||||
import { switchMap, map } from 'rxjs/operators';
|
||||
import { CampaignService } from '../services/campaign.service';
|
||||
import { CharacterService } from '../services/character.service';
|
||||
import { NpcService } from '../services/npc.service';
|
||||
import { TreeItem } from '../services/layout.service';
|
||||
import { Arc, Chapter, Scene } from '../services/campaign.model';
|
||||
import { Character } from '../services/character.model';
|
||||
import { Npc } from '../services/npc.model';
|
||||
|
||||
/**
|
||||
* Helper — charge l'arborescence complète d'une campagne (arcs -> chapitres -> scènes)
|
||||
@@ -19,20 +21,23 @@ export interface CampaignTreeData {
|
||||
chaptersByArc: Record<string, Chapter[]>;
|
||||
scenesByChapter: Record<string, Scene[]>;
|
||||
characters: Character[];
|
||||
npcs: Npc[];
|
||||
}
|
||||
|
||||
export function loadCampaignTreeData(
|
||||
service: CampaignService,
|
||||
campaignId: string,
|
||||
characterService: CharacterService
|
||||
characterService: CharacterService,
|
||||
npcService: NpcService
|
||||
): Observable<CampaignTreeData> {
|
||||
return forkJoin({
|
||||
arcs: service.getArcs(campaignId),
|
||||
characters: characterService.getByCampaign(campaignId)
|
||||
characters: characterService.getByCampaign(campaignId),
|
||||
npcs: npcService.getByCampaign(campaignId)
|
||||
}).pipe(
|
||||
switchMap(({ arcs, characters }) => {
|
||||
switchMap(({ arcs, characters, npcs }) => {
|
||||
if (arcs.length === 0) {
|
||||
return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters });
|
||||
return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters, npcs });
|
||||
}
|
||||
const chapterCalls = arcs.map(a =>
|
||||
service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters })))
|
||||
@@ -47,7 +52,7 @@ export function loadCampaignTreeData(
|
||||
});
|
||||
|
||||
if (allChapters.length === 0) {
|
||||
return of({ arcs, chaptersByArc, scenesByChapter: {}, characters });
|
||||
return of({ arcs, chaptersByArc, scenesByChapter: {}, characters, npcs });
|
||||
}
|
||||
const sceneCalls = allChapters.map(c =>
|
||||
service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes })))
|
||||
@@ -56,7 +61,7 @@ export function loadCampaignTreeData(
|
||||
map(sceneResults => {
|
||||
const scenesByChapter: Record<string, Scene[]> = {};
|
||||
sceneResults.forEach(r => { scenesByChapter[r.chapterId] = r.scenes; });
|
||||
return { arcs, chaptersByArc, scenesByChapter, characters };
|
||||
return { arcs, chaptersByArc, scenesByChapter, characters, npcs };
|
||||
})
|
||||
);
|
||||
})
|
||||
@@ -83,13 +88,13 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
||||
|
||||
const charactersNode: TreeItem = {
|
||||
id: 'characters-root',
|
||||
label: 'Personnages',
|
||||
label: 'PJ',
|
||||
iconKey: 'users',
|
||||
children: characterItems,
|
||||
meta: characterItems.length ? String(characterItems.length) : undefined,
|
||||
sectionHeaderBefore: 'Personnages',
|
||||
// Note : si pas d'arcs, le filet au-dessus de "Personnages" est masqué par CSS
|
||||
// (:first-child), ce qui est voulu — on ne veut pas de ligne seule en haut.
|
||||
// Note : le section header "Personnages" est porté par le premier nœud (PJ).
|
||||
// Le filet au-dessus est masqué par CSS si c'est le tout premier item de la sidebar.
|
||||
createActions: [{
|
||||
id: 'new-character',
|
||||
label: 'Nouveau PJ',
|
||||
@@ -98,6 +103,28 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
||||
}]
|
||||
};
|
||||
|
||||
const sortedNpcs = [...data.npcs].sort(byName);
|
||||
const npcItems: TreeItem[] = sortedNpcs.map(n => ({
|
||||
id: `npc-${n.id}`,
|
||||
label: n.name,
|
||||
route: `/campaigns/${campaignId}/npcs/${n.id}/edit`
|
||||
}));
|
||||
|
||||
const npcsNode: TreeItem = {
|
||||
id: 'npcs-root',
|
||||
label: 'PNJ',
|
||||
iconKey: 'c-drama',
|
||||
children: npcItems,
|
||||
meta: npcItems.length ? String(npcItems.length) : undefined,
|
||||
// Pas de sectionHeaderBefore : on reste sous le header "Personnages" du nœud PJ.
|
||||
createActions: [{
|
||||
id: 'new-npc',
|
||||
label: 'Nouveau PNJ',
|
||||
route: `/campaigns/${campaignId}/npcs/create`,
|
||||
actionIcon: 'plus'
|
||||
}]
|
||||
};
|
||||
|
||||
const sortedArcs = [...data.arcs].sort(byName);
|
||||
|
||||
const arcNodes: TreeItem[] = sortedArcs.map((arc, idx) => {
|
||||
@@ -143,5 +170,5 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
||||
};
|
||||
});
|
||||
|
||||
return [...arcNodes, charactersNode];
|
||||
return [...arcNodes, charactersNode, npcsNode];
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@ import { Component, EventEmitter, OnInit, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { LucideAngularModule, BookCopy, X } from 'lucide-angular';
|
||||
import { LoreService } from '../../services/lore.service';
|
||||
import { Lore } from '../../services/lore.model';
|
||||
import { GameSystemService } from '../../services/game-system.service';
|
||||
import { GameSystem } from '../../services/game-system.model';
|
||||
import { LoreService } from '../../../services/lore.service';
|
||||
import { Lore } from '../../../services/lore.model';
|
||||
import { GameSystemService } from '../../../services/game-system.service';
|
||||
import { GameSystem } from '../../../services/game-system.model';
|
||||
|
||||
/**
|
||||
* Payload émis vers le parent à la création d'une campagne.
|
||||
@@ -70,32 +70,75 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="detail-section characters-section" *ngIf="!editing">
|
||||
<section class="detail-section personas-section" *ngIf="!editing">
|
||||
<div class="section-header">
|
||||
<h2>Personnages joueurs</h2>
|
||||
<button class="btn-add" (click)="createCharacter()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Nouveau PJ
|
||||
</button>
|
||||
<h2>Personnages</h2>
|
||||
</div>
|
||||
|
||||
<div class="characters-grid" *ngIf="characters.length > 0">
|
||||
<div class="character-card" *ngFor="let character of characters" (click)="editCharacter(character)">
|
||||
<lucide-icon [img]="User" [size]="20" class="character-icon"></lucide-icon>
|
||||
<div class="character-info">
|
||||
<span class="character-name">{{ character.name }}</span>
|
||||
<span class="character-snippet">{{ characterSnippet(character) }}</span>
|
||||
<!-- Sous-section : Personnages joueurs (PJ) -->
|
||||
<div class="persona-subsection">
|
||||
<div class="subsection-header">
|
||||
<h3>
|
||||
<lucide-icon [img]="User" [size]="16"></lucide-icon>
|
||||
Personnages joueurs
|
||||
<span class="count-badge" *ngIf="characters.length > 0">{{ characters.length }}</span>
|
||||
</h3>
|
||||
<button class="btn-add" (click)="createCharacter()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Nouveau PJ
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="characters-grid" *ngIf="characters.length > 0">
|
||||
<div class="character-card" *ngFor="let character of characters" (click)="editCharacter(character)">
|
||||
<lucide-icon [img]="User" [size]="20" class="character-icon"></lucide-icon>
|
||||
<div class="character-info">
|
||||
<span class="character-name">{{ character.name }}</span>
|
||||
<span class="character-snippet">{{ personaSnippet(character) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-state empty-state--compact" *ngIf="characters.length === 0">
|
||||
<p>Aucun personnage joueur pour le moment.</p>
|
||||
<button class="btn-add-first" (click)="createCharacter()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Créer votre premier PJ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" *ngIf="characters.length === 0">
|
||||
<lucide-icon [img]="User" [size]="40" class="empty-icon"></lucide-icon>
|
||||
<p>Aucun personnage joueur pour le moment.</p>
|
||||
<button class="btn-add-first" (click)="createCharacter()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Créer votre premier PJ
|
||||
</button>
|
||||
<!-- Sous-section : Personnages non-joueurs (PNJ) -->
|
||||
<div class="persona-subsection">
|
||||
<div class="subsection-header">
|
||||
<h3>
|
||||
<lucide-icon [img]="Drama" [size]="16"></lucide-icon>
|
||||
Personnages non-joueurs
|
||||
<span class="count-badge" *ngIf="npcs.length > 0">{{ npcs.length }}</span>
|
||||
</h3>
|
||||
<button class="btn-add" (click)="createNpc()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Nouveau PNJ
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="characters-grid" *ngIf="npcs.length > 0">
|
||||
<div class="character-card" *ngFor="let npc of npcs" (click)="editNpc(npc)">
|
||||
<lucide-icon [img]="Drama" [size]="20" class="character-icon character-icon--npc"></lucide-icon>
|
||||
<div class="character-info">
|
||||
<span class="character-name">{{ npc.name }}</span>
|
||||
<span class="character-snippet">{{ personaSnippet(npc) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-state empty-state--compact" *ngIf="npcs.length === 0">
|
||||
<p>Aucun PNJ pour le moment.</p>
|
||||
<button class="btn-add-first" (click)="createNpc()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Créer votre premier PNJ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -197,6 +197,54 @@
|
||||
}
|
||||
|
||||
|
||||
// Encart "Personnages" qui regroupe les sous-sections PJ et PNJ.
|
||||
.personas-section {
|
||||
|
||||
.persona-subsection + .persona-subsection {
|
||||
margin-top: 1.75rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #1f2937;
|
||||
}
|
||||
}
|
||||
|
||||
.subsection-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #d1d5db;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin: 0;
|
||||
|
||||
lucide-icon { color: #a78bfa; }
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
padding: 0 0.45rem;
|
||||
background: #1f2937;
|
||||
color: #a78bfa;
|
||||
border-radius: 999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.characters-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
@@ -243,8 +291,23 @@
|
||||
|
||||
.empty-icon { color: #374151; }
|
||||
p { font-size: 0.95rem; }
|
||||
|
||||
// Variante condensée pour les sous-sections PJ/PNJ — pas besoin du
|
||||
// padding vertical massif quand l'encart parent en porte déjà.
|
||||
&.empty-state--compact {
|
||||
padding: 1.5rem 1rem;
|
||||
gap: 0.75rem;
|
||||
|
||||
p {
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Variante d'icône pour les cartes PNJ (rouge-violet pour différencier des PJ).
|
||||
.character-icon--npc { color: #c084fc !important; }
|
||||
|
||||
.btn-add-first {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2,21 +2,23 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, User, Dices } from 'lucide-angular';
|
||||
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, User, Dices, Drama } from 'lucide-angular';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError, switchMap, filter, map } from 'rxjs/operators';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { LoreService } from '../../services/lore.service';
|
||||
import { GameSystemService } from '../../services/game-system.service';
|
||||
import { GameSystem } from '../../services/game-system.model';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
import { Character } from '../../services/character.model';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { Campaign, Arc } from '../../services/campaign.model';
|
||||
import { Lore } from '../../services/lore.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree, CampaignTreeData } from '../campaign-tree.helper';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { LoreService } from '../../../services/lore.service';
|
||||
import { GameSystemService } from '../../../services/game-system.service';
|
||||
import { GameSystem } from '../../../services/game-system.model';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { Character } from '../../../services/character.model';
|
||||
import { Npc } from '../../../services/npc.model';
|
||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
import { Campaign, Arc } from '../../../services/campaign.model';
|
||||
import { Lore } from '../../../services/lore.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree, CampaignTreeData } from '../../campaign-tree.helper';
|
||||
|
||||
@Component({
|
||||
selector: 'app-campaign-detail',
|
||||
@@ -33,6 +35,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
readonly Trash2 = Trash2;
|
||||
readonly User = User;
|
||||
readonly Dices = Dices;
|
||||
readonly Drama = Drama;
|
||||
|
||||
campaign: Campaign | null = null;
|
||||
arcs: Arc[] = [];
|
||||
@@ -48,6 +51,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
linkedGameSystem: GameSystem | null = null;
|
||||
/** Fiches de personnages (PJ) de la campagne. */
|
||||
characters: Character[] = [];
|
||||
/** Fiches de personnages non-joueurs (PNJ) de la campagne. */
|
||||
npcs: Npc[] = [];
|
||||
|
||||
/** Mode édition inline. */
|
||||
editing = false;
|
||||
@@ -63,6 +68,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
private loreService: LoreService,
|
||||
private gameSystemService: GameSystemService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
) {}
|
||||
@@ -77,8 +83,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
switchMap(id => forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(id),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService).pipe(
|
||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [] } as CampaignTreeData))
|
||||
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService).pipe(
|
||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [] } as CampaignTreeData))
|
||||
)
|
||||
}))
|
||||
).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||
@@ -87,6 +93,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
this.loadLinkedLore(campaign);
|
||||
this.loadLinkedGameSystem(campaign);
|
||||
this.loadCharacters(campaign.id!);
|
||||
this.loadNpcs(campaign.id!);
|
||||
this.arcs = treeData.arcs;
|
||||
this.chapterCountByArc = this.computeChapterCounts(treeData);
|
||||
this.showLayout(allCampaigns, treeData);
|
||||
@@ -111,8 +118,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(id),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService).pipe(
|
||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [] } as CampaignTreeData))
|
||||
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService).pipe(
|
||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [] } as CampaignTreeData))
|
||||
)
|
||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||
this.campaign = campaign;
|
||||
@@ -120,6 +127,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
this.loadLinkedLore(campaign);
|
||||
this.loadLinkedGameSystem(campaign);
|
||||
this.loadCharacters(campaign.id!);
|
||||
this.loadNpcs(campaign.id!);
|
||||
this.arcs = treeData.arcs;
|
||||
this.chapterCountByArc = this.computeChapterCounts(treeData);
|
||||
this.showLayout(allCampaigns, treeData);
|
||||
@@ -159,11 +167,28 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
).subscribe(list => this.characters = list);
|
||||
}
|
||||
|
||||
/** Symétrique pour les PNJ. */
|
||||
private loadNpcs(campaignId: string): void {
|
||||
this.npcService.getByCampaign(campaignId).pipe(
|
||||
catchError(() => of([] as Npc[]))
|
||||
).subscribe(list => this.npcs = list);
|
||||
}
|
||||
|
||||
createCharacter(): void {
|
||||
if (!this.campaign) return;
|
||||
this.router.navigate(['/campaigns', this.campaign.id, 'characters', 'create']);
|
||||
}
|
||||
|
||||
createNpc(): void {
|
||||
if (!this.campaign) return;
|
||||
this.router.navigate(['/campaigns', this.campaign.id, 'npcs', 'create']);
|
||||
}
|
||||
|
||||
editNpc(npc: Npc): void {
|
||||
if (!this.campaign || !npc.id) return;
|
||||
this.router.navigate(['/campaigns', this.campaign.id, 'npcs', npc.id, 'edit']);
|
||||
}
|
||||
|
||||
editCharacter(character: Character): void {
|
||||
if (!this.campaign || !character.id) return;
|
||||
this.router.navigate(['/campaigns', this.campaign.id, 'characters', character.id, 'edit']);
|
||||
@@ -179,10 +204,13 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
this.router.navigate(['/campaigns', this.campaign.id, 'arcs', arc.id]);
|
||||
}
|
||||
|
||||
/** Extrait une ligne de résumé depuis le markdown (1re ligne non-vide, non-titre). */
|
||||
characterSnippet(c: Character): string {
|
||||
if (!c.markdownContent) return '(Fiche vide)';
|
||||
const firstMeaningful = c.markdownContent
|
||||
/**
|
||||
* Extrait une ligne de résumé depuis le markdown (1re ligne non-vide, non-titre).
|
||||
* Générique : utilisé pour les fiches PJ comme PNJ (mêmes besoins d'aperçu carte).
|
||||
*/
|
||||
personaSnippet(p: { markdownContent?: string | null }): string {
|
||||
if (!p.markdownContent) return '(Fiche vide)';
|
||||
const firstMeaningful = p.markdownContent
|
||||
.split('\n')
|
||||
.map(l => l.trim())
|
||||
.find(l => l && !l.startsWith('#'));
|
||||
@@ -192,6 +220,11 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
: firstMeaningful;
|
||||
}
|
||||
|
||||
/** Alias gardé pour compatibilité avec les anciens templates. */
|
||||
characterSnippet(c: Character): string {
|
||||
return this.personaSnippet(c);
|
||||
}
|
||||
|
||||
private showLayout(allCampaigns: Campaign[], data: CampaignTreeData): void {
|
||||
const campaignId = this.campaign!.id!;
|
||||
const globalItems: GlobalItem[] = allCampaigns.map(c => ({
|
||||
@@ -4,7 +4,7 @@ import { Router } from '@angular/router';
|
||||
import { LucideAngularModule, Map, Plus } from 'lucide-angular';
|
||||
import { CampaignService } from '../services/campaign.service';
|
||||
import { Campaign } from '../services/campaign.model';
|
||||
import { CampaignCreateComponent, CampaignCreatePayload } from './campaign-create/campaign-create.component';
|
||||
import { CampaignCreateComponent, CampaignCreatePayload } from './campaign/campaign-create/campaign-create.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-campaigns',
|
||||
|
||||
@@ -4,13 +4,14 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angula
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule } from 'lucide-angular';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { Campaign } from '../../services/campaign.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||
import { Campaign } from '../../../services/campaign.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||
|
||||
/**
|
||||
* Écran de création d'un nouveau chapitre rattaché à un arc.
|
||||
@@ -39,6 +40,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private layoutService: LayoutService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
@@ -57,7 +59,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
|
||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||
const currentArc = treeData.arcs.find(a => a.id === this.arcId);
|
||||
this.arcName = currentArc?.name ?? '';
|
||||
@@ -5,19 +5,20 @@ import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { Campaign, Chapter } from '../../services/campaign.model';
|
||||
import { Page } from '../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
|
||||
import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
||||
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { PageService } from '../../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
import { Campaign, Chapter } from '../../../services/campaign.model';
|
||||
import { Page } from '../../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
|
||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||
|
||||
/**
|
||||
* Écran de détail/modification d'un Chapitre.
|
||||
@@ -67,6 +68,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
@@ -104,7 +106,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
chapter: this.campaignService.getChapterById(this.chapterId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
@@ -3,12 +3,13 @@ import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule, ArrowLeft } from 'lucide-angular';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { Campaign, Chapter, Scene } from '../../services/campaign.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
import { Campaign, Chapter, Scene } from '../../../services/campaign.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||
|
||||
interface GraphNode { id: string; name: string; displayName: string; x: number; y: number; }
|
||||
interface GraphEdge { key: string; label: string; x1: number; y1: number; x2: number; y2: number; labelX: number; labelY: number; }
|
||||
@@ -68,6 +69,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
) {}
|
||||
@@ -87,7 +89,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
chapter: this.campaignService.getChapterById(this.chapterId),
|
||||
scenes: this.campaignService.getScenes(this.chapterId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
|
||||
}).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => {
|
||||
this.chapter = chapter;
|
||||
this.scenes = scenes;
|
||||
@@ -4,16 +4,17 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Pencil, Network, Trash2 } from 'lucide-angular';
|
||||
import { resolveCampaignIcon } from '../campaign-icons';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { Campaign, Chapter } from '../../services/campaign.model';
|
||||
import { Page } from '../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
||||
import { resolveCampaignIcon } from '../../campaign-icons';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { PageService } from '../../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
import { Campaign, Chapter } from '../../../services/campaign.model';
|
||||
import { Page } from '../../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||
|
||||
/**
|
||||
* Écran de consultation d'un Chapitre (lecture seule).
|
||||
@@ -45,6 +46,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
@@ -71,7 +73,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
chapter: this.campaignService.getChapterById(this.chapterId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
@@ -3,9 +3,9 @@ import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lucide-angular';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
import { Character } from '../../services/character.model';
|
||||
import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { Character } from '../../../services/character.model';
|
||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
|
||||
/**
|
||||
* Éditeur plein écran d'une fiche de personnage (PJ).
|
||||
82
web/src/app/campaigns/npc/npc-edit/npc-edit.component.html
Normal file
82
web/src/app/campaigns/npc/npc-edit/npc-edit.component.html
Normal file
@@ -0,0 +1,82 @@
|
||||
<div class="ne-page">
|
||||
|
||||
<div class="ne-header">
|
||||
<button class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
Retour à la campagne
|
||||
</button>
|
||||
<div class="header-row">
|
||||
<h1>
|
||||
<lucide-icon [img]="Drama" [size]="22"></lucide-icon>
|
||||
{{ npcId ? 'Éditer le PNJ' : 'Nouveau PNJ' }}
|
||||
</h1>
|
||||
<button
|
||||
*ngIf="npcId"
|
||||
type="button"
|
||||
class="btn-ai"
|
||||
(click)="toggleChat()"
|
||||
[class.active]="chatOpen"
|
||||
title="Ouvrir l'Assistant IA pour dialoguer autour de ce PNJ">
|
||||
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||
Assistant IA
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ne-form">
|
||||
|
||||
<div class="field">
|
||||
<label>Nom du PNJ *</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="name"
|
||||
name="name"
|
||||
placeholder="Ex: Borin le forgeron, Dame Elara, Kael l'aubergiste..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field content-field">
|
||||
<label>Fiche (markdown)</label>
|
||||
<p class="hint">
|
||||
Tout en markdown libre : apparence, motivations, faction, secrets, stats, notes MJ…
|
||||
À terme, l'IA pourra exploiter ces infos pour incarner le PNJ avec cohérence dans les scènes.
|
||||
</p>
|
||||
<textarea
|
||||
[(ngModel)]="markdownContent"
|
||||
name="markdownContent"
|
||||
rows="22"
|
||||
placeholder="# Borin le forgeron **Race :** Nain **Faction :** Clan Feuillefer **Statut :** Vivant ## Apparence Barbe rousse tressée, tablier de cuir brûlé... ## Motivations Venger son clan décimé par les orcs il y a 10 hivers. ## Notes MJ (secret) Connaît l'emplacement du marteau de Durin..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
|
||||
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
|
||||
{{ npcId ? 'Enregistrer' : 'Créer' }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="back()">Annuler</button>
|
||||
<span class="spacer"></span>
|
||||
<button
|
||||
*ngIf="npcId"
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
(click)="deleteNpc()">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<app-ai-chat-drawer
|
||||
*ngIf="npcId && campaignId"
|
||||
[campaignId]="campaignId"
|
||||
entityType="npc"
|
||||
[entityId]="npcId"
|
||||
[isOpen]="chatOpen"
|
||||
welcomeMessage="Je vois cette fiche de PNJ. Demande-moi de proposer apparence, motivations, secrets, ou répliques signatures."
|
||||
[quickSuggestions]="chatQuickSuggestions"
|
||||
(close)="chatOpen = false">
|
||||
</app-ai-chat-drawer>
|
||||
157
web/src/app/campaigns/npc/npc-edit/npc-edit.component.scss
Normal file
157
web/src/app/campaigns/npc/npc-edit/npc-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);
|
||||
}
|
||||
}
|
||||
109
web/src/app/campaigns/npc/npc-edit/npc-edit.component.ts
Normal file
109
web/src/app/campaigns/npc/npc-edit/npc-edit.component.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, Save, ArrowLeft, Drama, Trash2, Sparkles } from 'lucide-angular';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
|
||||
/**
|
||||
* Éditeur plein écran d'une fiche de PNJ.
|
||||
* Double rôle création/édition :
|
||||
* - `/campaigns/:campaignId/npcs/create` → POST
|
||||
* - `/campaigns/:campaignId/npcs/:npcId/edit` → PUT
|
||||
*
|
||||
* MVP : name + markdown libre. L'Assistant IA est branché en mode édition
|
||||
* (focus entityType="npc") pour proposer apparence, motivations, secrets...
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-npc-edit',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent],
|
||||
templateUrl: './npc-edit.component.html',
|
||||
styleUrls: ['./npc-edit.component.scss']
|
||||
})
|
||||
export class NpcEditComponent implements OnInit {
|
||||
readonly Save = Save;
|
||||
readonly ArrowLeft = ArrowLeft;
|
||||
readonly Drama = Drama;
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Sparkles = Sparkles;
|
||||
|
||||
/** État drawer chat IA focalisé sur ce PNJ. */
|
||||
chatOpen = false;
|
||||
readonly chatQuickSuggestions = [
|
||||
'Propose une apparence et une posture marquantes',
|
||||
'Suggère 2 motivations et un secret pour ce PNJ',
|
||||
'Imagine 3 répliques signatures qui le caractérisent'
|
||||
];
|
||||
|
||||
toggleChat(): void { this.chatOpen = !this.chatOpen; }
|
||||
|
||||
campaignId: string | null = null;
|
||||
npcId: string | null = null;
|
||||
|
||||
name = '';
|
||||
markdownContent = '';
|
||||
private order = 0;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private service: NpcService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
const params = this.route.snapshot.paramMap;
|
||||
this.campaignId = params.get('campaignId');
|
||||
this.npcId = params.get('npcId');
|
||||
|
||||
if (this.npcId) {
|
||||
this.service.getById(this.npcId).subscribe({
|
||||
next: (n) => {
|
||||
this.name = n.name;
|
||||
this.markdownContent = n.markdownContent ?? '';
|
||||
this.order = n.order ?? 0;
|
||||
},
|
||||
error: () => this.back()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (!this.name.trim() || !this.campaignId) return;
|
||||
const req = this.npcId
|
||||
? this.service.update(this.npcId, {
|
||||
id: this.npcId,
|
||||
name: this.name.trim(),
|
||||
markdownContent: this.markdownContent || null,
|
||||
campaignId: this.campaignId,
|
||||
order: this.order
|
||||
})
|
||||
: this.service.create({
|
||||
name: this.name.trim(),
|
||||
markdownContent: this.markdownContent || null,
|
||||
campaignId: this.campaignId
|
||||
});
|
||||
req.subscribe({
|
||||
next: () => this.back(),
|
||||
error: () => console.error('Erreur sauvegarde Npc')
|
||||
});
|
||||
}
|
||||
|
||||
deleteNpc(): void {
|
||||
if (!this.npcId) return;
|
||||
if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irréversible.`)) return;
|
||||
this.service.delete(this.npcId).subscribe({
|
||||
next: () => this.back(),
|
||||
error: () => console.error('Erreur suppression Npc')
|
||||
});
|
||||
}
|
||||
|
||||
back(): void {
|
||||
if (this.campaignId) {
|
||||
this.router.navigate(['/campaigns', this.campaignId]);
|
||||
} else {
|
||||
this.router.navigate(['/campaigns']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,14 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angula
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule } from 'lucide-angular';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { Campaign } from '../../services/campaign.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||
import { Campaign } from '../../../services/campaign.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||
|
||||
/**
|
||||
* Écran de création d'une nouvelle scène rattachée à un chapitre.
|
||||
@@ -40,6 +41,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private layoutService: LayoutService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
@@ -59,7 +61,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
|
||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||
const currentChapter = (treeData.chaptersByArc[this.arcId] ?? []).find(c => c.id === this.chapterId);
|
||||
this.chapterName = currentChapter?.name ?? '';
|
||||
@@ -5,20 +5,21 @@ import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { Campaign, Scene, SceneBranch } from '../../services/campaign.model';
|
||||
import { Page } from '../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||
import { ExpandableSectionComponent } from '../../shared/expandable-section/expandable-section.component';
|
||||
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
|
||||
import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
||||
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { PageService } from '../../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
import { Campaign, Scene, SceneBranch } from '../../../services/campaign.model';
|
||||
import { Page } from '../../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||
import { ExpandableSectionComponent } from '../../../shared/expandable-section/expandable-section.component';
|
||||
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
|
||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||
|
||||
/**
|
||||
* Écran de détail/modification d'une Scène.
|
||||
@@ -71,6 +72,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
@@ -122,7 +124,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
scene: this.campaignService.getSceneById(this.sceneId),
|
||||
chapterScenes: this.campaignService.getScenes(this.chapterId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
@@ -4,16 +4,17 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
|
||||
import { resolveCampaignIcon } from '../campaign-icons';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { Campaign, Scene } from '../../services/campaign.model';
|
||||
import { Page } from '../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
||||
import { resolveCampaignIcon } from '../../campaign-icons';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { PageService } from '../../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
import { Campaign, Scene } from '../../../services/campaign.model';
|
||||
import { Page } from '../../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||
|
||||
/**
|
||||
* Écran de consultation d'une Scène (lecture seule).
|
||||
@@ -45,6 +46,7 @@ export class SceneViewComponent implements OnInit, OnDestroy {
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
@@ -74,7 +76,7 @@ export class SceneViewComponent implements OnInit, OnDestroy {
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
scene: this.campaignService.getSceneById(this.sceneId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
@@ -41,7 +41,7 @@ export type ChatStreamEvent =
|
||||
* décode ligne par ligne pour extraire les événements SSE.
|
||||
*/
|
||||
/** Type d'entité narrative focus pour le chat Campagne. */
|
||||
export type NarrativeEntityType = 'arc' | 'chapter' | 'scene' | 'character';
|
||||
export type NarrativeEntityType = 'arc' | 'chapter' | 'scene' | 'character' | 'npc';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AiChatService {
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface Conversation {
|
||||
export interface ConversationContext {
|
||||
loreId?: string | null;
|
||||
campaignId?: string | null;
|
||||
entityType?: 'page' | 'arc' | 'chapter' | 'scene' | 'character' | null;
|
||||
entityType?: 'page' | 'arc' | 'chapter' | 'scene' | 'character' | 'npc' | null;
|
||||
entityId?: string | null;
|
||||
}
|
||||
|
||||
|
||||
18
web/src/app/services/npc.model.ts
Normal file
18
web/src/app/services/npc.model.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Fiche de personnage non-joueur (PNJ) d'une campagne.
|
||||
* MVP : markdownContent libre (description, motivation, stats, notes MJ).
|
||||
* Évolution prévue : templating partagé PJ/PNJ piloté par GameSystem.
|
||||
*/
|
||||
export interface Npc {
|
||||
id?: string;
|
||||
name: string;
|
||||
markdownContent?: string | null;
|
||||
campaignId: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface NpcCreate {
|
||||
name: string;
|
||||
markdownContent?: string | null;
|
||||
campaignId: string;
|
||||
}
|
||||
34
web/src/app/services/npc.service.ts
Normal file
34
web/src/app/services/npc.service.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Npc, NpcCreate } from './npc.model';
|
||||
|
||||
/**
|
||||
* Service HTTP pour les fiches de PNJ d'une campagne.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class NpcService {
|
||||
private apiUrl = '/api/npcs';
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
getByCampaign(campaignId: string): Observable<Npc[]> {
|
||||
return this.http.get<Npc[]>(`${this.apiUrl}/campaign/${campaignId}`);
|
||||
}
|
||||
|
||||
getById(id: string): Observable<Npc> {
|
||||
return this.http.get<Npc>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
create(payload: NpcCreate): Observable<Npc> {
|
||||
return this.http.post<Npc>(this.apiUrl, payload);
|
||||
}
|
||||
|
||||
update(id: string, payload: Npc): Observable<Npc> {
|
||||
return this.http.put<Npc>(`${this.apiUrl}/${id}`, payload);
|
||||
}
|
||||
|
||||
delete(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
}
|
||||
@@ -31,5 +31,9 @@
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
}
|
||||
},
|
||||
"exclude": [
|
||||
"e2e/**/*",
|
||||
"playwright.config.ts"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user