2 Commits

Author SHA1 Message Date
550078268c Evolutions :
Some checks failed
Build & Push Images / build (brain) (push) Successful in 55s
Build & Push Images / build (core) (push) Successful in 1m35s
E2E Tests / e2e (push) Failing after 4m10s
Build & Push Images / build (web) (push) Successful in 2m0s
- Ajout d'icônes dans la scène, chapitre et arc
- Possibilité de bouger les cases dans la partie graphe et les textes associés si ces derniers ne sont pas visibles
- Changement sur le thème du graphe : mode sombre et plus blanc
- Barre d'action en haut, même pour la partie scène
- Mode sticky corrigé : plus de trou entre le haut du navigateur web et de la barre d'action

Passage version 0.6.5
2026-04-25 11:41:14 +02:00
0582690dca Correction d'un test unitaire
Some checks failed
E2E Tests / e2e (push) Failing after 3m43s
Ajout d'un champs image dans les templates par défaut en + du champs nom, description pour avoir un exemple
Correction du visuel du champs d'ajout lors de la modification d'un template (apparition ligne pleine au lieu de texte en pointillé)
Ajout d'un intercepteur pour la partie démo de l'application afin de bien rafraichir le cache angular lorsque le temps de démo est expiré
2026-04-25 09:23:56 +02:00
60 changed files with 736 additions and 109 deletions

View File

@@ -40,7 +40,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
app = FastAPI( app = FastAPI(
title="LoreMind Brain", title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.", description="Backend IA pour la génération de contenu narratif.",
version="0.6.2", version="0.6.5",
) )

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId> <groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId> <artifactId>loremind-core</artifactId>
<version>0.6.2</version> <version>0.6.5</version>
<name>LoreMind Core</name> <name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description> <description>Backend Core - Architecture Hexagonale</description>

View File

@@ -36,11 +36,16 @@ public class ArcService {
public record DeletionImpact(int chapters, int scenes) {} public record DeletionImpact(int chapters, int scenes) {}
public Arc createArc(String name, String description, String campaignId, int order) { public Arc createArc(String name, String description, String campaignId, int order) {
return createArc(name, description, campaignId, order, null);
}
public Arc createArc(String name, String description, String campaignId, int order, String icon) {
Arc arc = Arc.builder() Arc arc = Arc.builder()
.name(name) .name(name)
.description(description) .description(description)
.campaignId(campaignId) .campaignId(campaignId)
.order(order) .order(order)
.icon(icon)
.build(); .build();
return arcRepository.save(arc); return arcRepository.save(arc);
} }

View File

@@ -30,11 +30,16 @@ public class ChapterService {
public record DeletionImpact(int scenes) {} public record DeletionImpact(int scenes) {}
public Chapter createChapter(String name, String description, String arcId, int order) { public Chapter createChapter(String name, String description, String arcId, int order) {
return createChapter(name, description, arcId, order, null);
}
public Chapter createChapter(String name, String description, String arcId, int order, String icon) {
Chapter chapter = Chapter.builder() Chapter chapter = Chapter.builder()
.name(name) .name(name)
.description(description) .description(description)
.arcId(arcId) .arcId(arcId)
.order(order) .order(order)
.icon(icon)
.build(); .build();
return chapterRepository.save(chapter); return chapterRepository.save(chapter);
} }

View File

@@ -26,11 +26,16 @@ public class SceneService {
} }
public Scene createScene(String name, String description, String chapterId, int order) { public Scene createScene(String name, String description, String chapterId, int order) {
return createScene(name, description, chapterId, order, null);
}
public Scene createScene(String name, String description, String chapterId, int order, String icon) {
Scene scene = Scene.builder() Scene scene = Scene.builder()
.name(name) .name(name)
.description(description) .description(description)
.chapterId(chapterId) .chapterId(chapterId)
.order(order) .order(order)
.icon(icon)
.build(); .build();
return sceneRepository.save(scene); return sceneRepository.save(scene);
} }

View File

@@ -21,6 +21,9 @@ public class Arc {
private String campaignId; // Référence vers la Campaign parente private String campaignId; // Référence vers la Campaign parente
private int order; // Ordre de l'arc dans la campagne private int order; // Ordre de l'arc dans la campagne
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// Champs narratifs enrichis (voir docs/maquettes/campagne/détail/) // Champs narratifs enrichis (voir docs/maquettes/campagne/détail/)
private String themes; // Thèmes principaux explorés dans cet arc private String themes; // Thèmes principaux explorés dans cet arc
private String stakes; // Enjeux globaux pour les personnages private String stakes; // Enjeux globaux pour les personnages

View File

@@ -21,6 +21,9 @@ public class Chapter {
private String arcId; // Référence vers l'Arc parent private String arcId; // Référence vers l'Arc parent
private int order; // Ordre du chapitre dans l'arc private int order; // Ordre du chapitre dans l'arc
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// Champs narratifs enrichis (voir docs/maquettes/campagne/détail/) // Champs narratifs enrichis (voir docs/maquettes/campagne/détail/)
private String gmNotes; // Notes privées du MJ (non exportées vers FoundryVTT) private String gmNotes; // Notes privées du MJ (non exportées vers FoundryVTT)
private String playerObjectives; // Objectifs des joueurs dans ce chapitre private String playerObjectives; // Objectifs des joueurs dans ce chapitre

View File

@@ -21,6 +21,9 @@ public class Scene {
private String chapterId; // Référence vers le Chapter parent private String chapterId; // Référence vers le Chapter parent
private int order; // Ordre de la scène dans le chapitre private int order; // Ordre de la scène dans le chapitre
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// === Contexte et ambiance === // === Contexte et ambiance ===
private String location; // Lieu de la scène (ex: Taverne du Dragon d'Or) private String location; // Lieu de la scène (ex: Taverne du Dragon d'Or)
private String timing; // Moment (ex: Soir, à la tombée de la nuit) private String timing; // Moment (ex: Soir, à la tombée de la nuit)

View File

@@ -37,6 +37,9 @@ public class ArcJpaEntity {
@Column(name = "\"order\"", nullable = false) @Column(name = "\"order\"", nullable = false)
private int order; private int order;
@Column
private String icon;
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update) // Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update)
@Column(columnDefinition = "TEXT") @Column(columnDefinition = "TEXT")
private String themes; private String themes;

View File

@@ -37,6 +37,9 @@ public class ChapterJpaEntity {
@Column(name = "\"order\"", nullable = false) @Column(name = "\"order\"", nullable = false)
private int order; private int order;
@Column
private String icon;
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update) // Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update)
@Column(name = "gm_notes", columnDefinition = "TEXT") @Column(name = "gm_notes", columnDefinition = "TEXT")
private String gmNotes; private String gmNotes;

View File

@@ -39,6 +39,9 @@ public class SceneJpaEntity {
@Column(name = "\"order\"", nullable = false) @Column(name = "\"order\"", nullable = false)
private int order; private int order;
@Column
private String icon;
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate (ddl-auto=update) // Champs narratifs enrichis — ajoutés automatiquement par Hibernate (ddl-auto=update)
// Contexte et ambiance // Contexte et ambiance

View File

@@ -71,6 +71,7 @@ public class PostgresArcRepository implements ArcRepository {
.description(jpaEntity.getDescription()) .description(jpaEntity.getDescription())
.campaignId(jpaEntity.getCampaignId().toString()) .campaignId(jpaEntity.getCampaignId().toString())
.order(jpaEntity.getOrder()) .order(jpaEntity.getOrder())
.icon(jpaEntity.getIcon())
.themes(jpaEntity.getThemes()) .themes(jpaEntity.getThemes())
.stakes(jpaEntity.getStakes()) .stakes(jpaEntity.getStakes())
.gmNotes(jpaEntity.getGmNotes()) .gmNotes(jpaEntity.getGmNotes())
@@ -99,6 +100,7 @@ public class PostgresArcRepository implements ArcRepository {
.description(arc.getDescription()) .description(arc.getDescription())
.campaignId(Long.parseLong(arc.getCampaignId())) .campaignId(Long.parseLong(arc.getCampaignId()))
.order(arc.getOrder()) .order(arc.getOrder())
.icon(arc.getIcon())
.themes(arc.getThemes()) .themes(arc.getThemes())
.stakes(arc.getStakes()) .stakes(arc.getStakes())
.gmNotes(arc.getGmNotes()) .gmNotes(arc.getGmNotes())

View File

@@ -71,6 +71,7 @@ public class PostgresChapterRepository implements ChapterRepository {
.description(jpaEntity.getDescription()) .description(jpaEntity.getDescription())
.arcId(jpaEntity.getArcId().toString()) .arcId(jpaEntity.getArcId().toString())
.order(jpaEntity.getOrder()) .order(jpaEntity.getOrder())
.icon(jpaEntity.getIcon())
.gmNotes(jpaEntity.getGmNotes()) .gmNotes(jpaEntity.getGmNotes())
.playerObjectives(jpaEntity.getPlayerObjectives()) .playerObjectives(jpaEntity.getPlayerObjectives())
.narrativeStakes(jpaEntity.getNarrativeStakes()) .narrativeStakes(jpaEntity.getNarrativeStakes())
@@ -96,6 +97,7 @@ public class PostgresChapterRepository implements ChapterRepository {
.description(chapter.getDescription()) .description(chapter.getDescription())
.arcId(Long.parseLong(chapter.getArcId())) .arcId(Long.parseLong(chapter.getArcId()))
.order(chapter.getOrder()) .order(chapter.getOrder())
.icon(chapter.getIcon())
.gmNotes(chapter.getGmNotes()) .gmNotes(chapter.getGmNotes())
.playerObjectives(chapter.getPlayerObjectives()) .playerObjectives(chapter.getPlayerObjectives())
.narrativeStakes(chapter.getNarrativeStakes()) .narrativeStakes(chapter.getNarrativeStakes())

View File

@@ -71,6 +71,7 @@ public class PostgresSceneRepository implements SceneRepository {
.description(jpaEntity.getDescription()) .description(jpaEntity.getDescription())
.chapterId(jpaEntity.getChapterId().toString()) .chapterId(jpaEntity.getChapterId().toString())
.order(jpaEntity.getOrder()) .order(jpaEntity.getOrder())
.icon(jpaEntity.getIcon())
.location(jpaEntity.getLocation()) .location(jpaEntity.getLocation())
.timing(jpaEntity.getTiming()) .timing(jpaEntity.getTiming())
.atmosphere(jpaEntity.getAtmosphere()) .atmosphere(jpaEntity.getAtmosphere())
@@ -104,6 +105,7 @@ public class PostgresSceneRepository implements SceneRepository {
.description(scene.getDescription()) .description(scene.getDescription())
.chapterId(Long.parseLong(scene.getChapterId())) .chapterId(Long.parseLong(scene.getChapterId()))
.order(scene.getOrder()) .order(scene.getOrder())
.icon(scene.getIcon())
.location(scene.getLocation()) .location(scene.getLocation())
.timing(scene.getTiming()) .timing(scene.getTiming())
.atmosphere(scene.getAtmosphere()) .atmosphere(scene.getAtmosphere())

View File

@@ -28,7 +28,7 @@ public class ArcController {
@PostMapping @PostMapping
public ResponseEntity<ArcDTO> createArc(@RequestBody ArcDTO arcDTO) { public ResponseEntity<ArcDTO> createArc(@RequestBody ArcDTO arcDTO) {
Arc arc = arcMapper.toDomain(arcDTO); Arc arc = arcMapper.toDomain(arcDTO);
Arc createdArc = arcService.createArc(arc.getName(), arc.getDescription(), arc.getCampaignId(), arc.getOrder()); Arc createdArc = arcService.createArc(arc.getName(), arc.getDescription(), arc.getCampaignId(), arc.getOrder(), arc.getIcon());
return ResponseEntity.ok(arcMapper.toDTO(createdArc)); return ResponseEntity.ok(arcMapper.toDTO(createdArc));
} }

View File

@@ -28,7 +28,7 @@ public class ChapterController {
@PostMapping @PostMapping
public ResponseEntity<ChapterDTO> createChapter(@RequestBody ChapterDTO chapterDTO) { public ResponseEntity<ChapterDTO> createChapter(@RequestBody ChapterDTO chapterDTO) {
Chapter chapter = chapterMapper.toDomain(chapterDTO); Chapter chapter = chapterMapper.toDomain(chapterDTO);
Chapter createdChapter = chapterService.createChapter(chapter.getName(), chapter.getDescription(), chapter.getArcId(), chapter.getOrder()); Chapter createdChapter = chapterService.createChapter(chapter.getName(), chapter.getDescription(), chapter.getArcId(), chapter.getOrder(), chapter.getIcon());
return ResponseEntity.ok(chapterMapper.toDTO(createdChapter)); return ResponseEntity.ok(chapterMapper.toDTO(createdChapter));
} }

View File

@@ -28,7 +28,7 @@ public class SceneController {
@PostMapping @PostMapping
public ResponseEntity<SceneDTO> createScene(@RequestBody SceneDTO sceneDTO) { public ResponseEntity<SceneDTO> createScene(@RequestBody SceneDTO sceneDTO) {
Scene scene = sceneMapper.toDomain(sceneDTO); Scene scene = sceneMapper.toDomain(sceneDTO);
Scene createdScene = sceneService.createScene(scene.getName(), scene.getDescription(), scene.getChapterId(), scene.getOrder()); Scene createdScene = sceneService.createScene(scene.getName(), scene.getDescription(), scene.getChapterId(), scene.getOrder(), scene.getIcon());
return ResponseEntity.ok(sceneMapper.toDTO(createdScene)); return ResponseEntity.ok(sceneMapper.toDTO(createdScene));
} }

View File

@@ -17,6 +17,9 @@ public class ArcDTO {
private String campaignId; private String campaignId;
private int order; private int order;
/** Cle d'icone (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// Champs narratifs enrichis // Champs narratifs enrichis
private String themes; private String themes;
private String stakes; private String stakes;

View File

@@ -17,6 +17,9 @@ public class ChapterDTO {
private String arcId; private String arcId;
private int order; private int order;
/** Cle d'icone (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// Champs narratifs enrichis // Champs narratifs enrichis
private String gmNotes; private String gmNotes;
private String playerObjectives; private String playerObjectives;

View File

@@ -17,6 +17,9 @@ public class SceneDTO {
private String chapterId; private String chapterId;
private int order; private int order;
/** Cle d'icone (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// Champs narratifs enrichis // Champs narratifs enrichis
private String location; private String location;
private String timing; private String timing;

View File

@@ -24,6 +24,7 @@ public class ArcMapper {
dto.setDescription(arc.getDescription()); dto.setDescription(arc.getDescription());
dto.setCampaignId(arc.getCampaignId()); dto.setCampaignId(arc.getCampaignId());
dto.setOrder(arc.getOrder()); dto.setOrder(arc.getOrder());
dto.setIcon(arc.getIcon());
dto.setThemes(arc.getThemes()); dto.setThemes(arc.getThemes());
dto.setStakes(arc.getStakes()); dto.setStakes(arc.getStakes());
dto.setGmNotes(arc.getGmNotes()); dto.setGmNotes(arc.getGmNotes());
@@ -46,6 +47,7 @@ public class ArcMapper {
.description(dto.getDescription()) .description(dto.getDescription())
.campaignId(dto.getCampaignId()) .campaignId(dto.getCampaignId())
.order(dto.getOrder()) .order(dto.getOrder())
.icon(dto.getIcon())
.themes(dto.getThemes()) .themes(dto.getThemes())
.stakes(dto.getStakes()) .stakes(dto.getStakes())
.gmNotes(dto.getGmNotes()) .gmNotes(dto.getGmNotes())

View File

@@ -24,6 +24,7 @@ public class ChapterMapper {
dto.setDescription(chapter.getDescription()); dto.setDescription(chapter.getDescription());
dto.setArcId(chapter.getArcId()); dto.setArcId(chapter.getArcId());
dto.setOrder(chapter.getOrder()); dto.setOrder(chapter.getOrder());
dto.setIcon(chapter.getIcon());
dto.setGmNotes(chapter.getGmNotes()); dto.setGmNotes(chapter.getGmNotes());
dto.setPlayerObjectives(chapter.getPlayerObjectives()); dto.setPlayerObjectives(chapter.getPlayerObjectives());
dto.setNarrativeStakes(chapter.getNarrativeStakes()); dto.setNarrativeStakes(chapter.getNarrativeStakes());
@@ -44,6 +45,7 @@ public class ChapterMapper {
.description(dto.getDescription()) .description(dto.getDescription())
.arcId(dto.getArcId()) .arcId(dto.getArcId())
.order(dto.getOrder()) .order(dto.getOrder())
.icon(dto.getIcon())
.gmNotes(dto.getGmNotes()) .gmNotes(dto.getGmNotes())
.playerObjectives(dto.getPlayerObjectives()) .playerObjectives(dto.getPlayerObjectives())
.narrativeStakes(dto.getNarrativeStakes()) .narrativeStakes(dto.getNarrativeStakes())

View File

@@ -27,6 +27,7 @@ public class SceneMapper {
dto.setDescription(scene.getDescription()); dto.setDescription(scene.getDescription());
dto.setChapterId(scene.getChapterId()); dto.setChapterId(scene.getChapterId());
dto.setOrder(scene.getOrder()); dto.setOrder(scene.getOrder());
dto.setIcon(scene.getIcon());
dto.setLocation(scene.getLocation()); dto.setLocation(scene.getLocation());
dto.setTiming(scene.getTiming()); dto.setTiming(scene.getTiming());
dto.setAtmosphere(scene.getAtmosphere()); dto.setAtmosphere(scene.getAtmosphere());
@@ -59,6 +60,7 @@ public class SceneMapper {
.description(dto.getDescription()) .description(dto.getDescription())
.chapterId(dto.getChapterId()) .chapterId(dto.getChapterId())
.order(dto.getOrder()) .order(dto.getOrder())
.icon(dto.getIcon())
.location(dto.getLocation()) .location(dto.getLocation())
.timing(dto.getTiming()) .timing(dto.getTiming())
.atmosphere(dto.getAtmosphere()) .atmosphere(dto.getAtmosphere())

View File

@@ -50,7 +50,21 @@ test.describe('Page creation', () => {
expect(created?.nodeId).toBe(seeded.rootFolderId); expect(created?.nodeId).toBe(seeded.rootFolderId);
}); });
test('submit is disabled until title, template and folder are set', async ({ page }) => { test('submit is disabled until title, template and folder are set', async ({ page, request }) => {
// On seed un 2ᵉ template pour empêcher l'auto-sélection (qui se déclenche
// quand un seul template a un defaultNodeId valide). Avec deux candidats,
// l'utilisateur doit choisir explicitement → on retrouve le comportement
// initial du test : submit disabled tant qu'un template n'est pas cliqué.
const secondFolderRes = await request.post('/api/lore-nodes', {
data: { loreId: seeded.id, name: 'Autre dossier', icon: 'folder', description: '' },
});
const secondFolderId = (await secondFolderRes.json()).id;
await seedTemplate(request, {
loreId: seeded.id,
defaultNodeId: secondFolderId,
name: `Second template ${Date.now()}`,
});
await page.goto(`/lore/${seeded.id}/pages/create`); await page.goto(`/lore/${seeded.id}/pages/create`);
const submit = page.getByRole('button', { name: /^Créer la page$/i }); const submit = page.getByRole('button', { name: /^Créer la page$/i });
@@ -66,6 +80,8 @@ test.describe('Page creation', () => {
test('entering on a folder-scoped route preselects that folder', async ({ page, request }) => { test('entering on a folder-scoped route preselects that folder', async ({ page, request }) => {
const pageTitle = `Page scoped ${Date.now()}`; const pageTitle = `Page scoped ${Date.now()}`;
// Dossier sans template par défaut → pas d'auto-sélection de template,
// l'utilisateur clique manuellement (ce qu'on veut tester ici).
const secondFolderRes = await request.post('/api/lore-nodes', { const secondFolderRes = await request.post('/api/lore-nodes', {
data: { loreId: seeded.id, name: 'Autre dossier', icon: 'folder', description: '' }, data: { loreId: seeded.id, name: 'Autre dossier', icon: 'folder', description: '' },
}); });
@@ -87,4 +103,31 @@ test.describe('Page creation', () => {
const pages = await getPagesForLore(request, seeded.id); const pages = await getPagesForLore(request, seeded.id);
expect(pages.find((p) => p.title === pageTitle)?.nodeId).toBe(secondFolderId); expect(pages.find((p) => p.title === pageTitle)?.nodeId).toBe(secondFolderId);
}); });
test('auto-selects the template on free route when it is the only candidate', async ({ page }) => {
// Le seed donne EXACTEMENT 1 template avec defaultNodeId valide → la
// logique d'auto-sélection doit s'enclencher au chargement.
await page.goto(`/lore/${seeded.id}/pages/create`);
await expect(page.locator('.template-card.selected', { hasText: template.name })).toBeVisible();
await expect(page.locator('#page-node')).toHaveValue(seeded.rootFolderId);
// Conséquence : juste taper un titre suffit pour activer le submit.
const submit = page.getByRole('button', { name: /^Créer la page$/i });
await expect(submit).toBeDisabled();
await page.getByLabel(/Titre de la page/i).fill('Auto');
await expect(submit).toBeEnabled();
});
test('auto-selects the template on folder-scoped route when its defaultNodeId matches', async ({
page,
}) => {
// Le template seedé pointe sur seeded.rootFolderId — entrer sur la route
// folder-scoped de ce dossier doit auto-sélectionner ce template.
await page.goto(`/lore/${seeded.id}/nodes/${seeded.rootFolderId}/pages/create`);
await expect(page.locator('.template-card.selected', { hasText: template.name })).toBeVisible();
await expect(page.locator('#page-node')).toHaveValue(seeded.rootFolderId);
await expect(page.locator('#page-node')).toBeDisabled();
});
}); });

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.6.1", "version": "0.6.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "loremind-web", "name": "loremind-web",
"version": "0.6.1", "version": "0.6.5",
"dependencies": { "dependencies": {
"@angular/animations": "^17.0.0", "@angular/animations": "^17.0.0",
"@angular/common": "^17.0.0", "@angular/common": "^17.0.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.6.2", "version": "0.6.5",
"description": "LoreMind Frontend - Angular", "description": "LoreMind Frontend - Angular",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",

View File

@@ -5,6 +5,10 @@
.main-content { .main-content {
flex: 1; flex: 1;
padding: 2rem; // padding-top: 0 — sinon le contenu defile dans la zone de padding
// au-dessus du `.page-header` sticky (top: 0 pin sur l'edge interne du
// padding-box). Chaque page-wrapper definit deja son propre padding-top
// qui devient l'unique source d'espacement haut.
padding: 0 2rem 2rem;
overflow-y: auto; overflow-y: auto;
} }

View File

@@ -27,6 +27,11 @@
</textarea> </textarea>
</div> </div>
<div class="field">
<label>Icône</label>
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
</div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid"> <button type="submit" class="btn-primary" [disabled]="form.invalid">
Créer l'arc Créer l'arc

View File

@@ -9,6 +9,8 @@ import { CharacterService } from '../../services/character.service';
import { LayoutService, GlobalItem } from '../../services/layout.service'; import { LayoutService, GlobalItem } from '../../services/layout.service';
import { Campaign } from '../../services/campaign.model'; import { Campaign } from '../../services/campaign.model';
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper'; 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). * Écran de création d'un nouvel Arc narratif (contexte Campagne).
@@ -18,15 +20,17 @@ import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper
@Component({ @Component({
selector: 'app-arc-create', selector: 'app-arc-create',
standalone: true, standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule], imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, IconPickerComponent],
templateUrl: './arc-create.component.html', templateUrl: './arc-create.component.html',
styleUrls: ['./arc-create.component.scss'] styleUrls: ['./arc-create.component.scss']
}) })
export class ArcCreateComponent implements OnInit, OnDestroy { export class ArcCreateComponent implements OnInit, OnDestroy {
readonly BookOpen = BookOpen; readonly BookOpen = BookOpen;
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
form: FormGroup; form: FormGroup;
campaignId = ''; campaignId = '';
selectedIcon: string | null = null;
private existingArcCount = 0; private existingArcCount = 0;
constructor( constructor(
@@ -80,7 +84,8 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
name: this.form.value.name, name: this.form.value.name,
description: this.form.value.description, description: this.form.value.description,
campaignId: this.campaignId, campaignId: this.campaignId,
order: this.existingArcCount + 1 order: this.existingArcCount + 1,
icon: this.selectedIcon
}).subscribe({ }).subscribe({
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', created.id]), next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', created.id]),
error: () => console.error('Erreur lors de la création de l\'arc') error: () => console.error('Erreur lors de la création de l\'arc')

View File

@@ -13,6 +13,14 @@
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon> <lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA Assistant IA
</button> </button>
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
<button type="button" class="btn-danger" (click)="delete()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
<button type="button" class="btn-primary" (click)="submit()" [disabled]="form.invalid">
Sauvegarder
</button>
</div> </div>
</div> </div>
@@ -63,6 +71,11 @@
</textarea> </textarea>
</div> </div>
<div class="field">
<label>Icône</label>
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
</div>
<div class="field-row"> <div class="field-row">
<div class="field"> <div class="field">
<label for="arc-edit-themes">Thèmes principaux</label> <label for="arc-edit-themes">Thèmes principaux</label>
@@ -136,17 +149,6 @@
</small> </small>
</div> </div>
<div class="form-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
Sauvegarder
</button>
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
<button type="button" class="btn-danger" (click)="delete()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</form> </form>
</div> </div>

View File

@@ -16,6 +16,8 @@ import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component'; import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component'; import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.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. * Écran de détail/modification d'un Arc.
@@ -29,13 +31,15 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
@Component({ @Component({
selector: 'app-arc-edit', selector: 'app-arc-edit',
standalone: true, standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent], imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent],
templateUrl: './arc-edit.component.html', templateUrl: './arc-edit.component.html',
styleUrls: ['./arc-edit.component.scss'] styleUrls: ['./arc-edit.component.scss']
}) })
export class ArcEditComponent implements OnInit, OnDestroy { export class ArcEditComponent implements OnInit, OnDestroy {
readonly Trash2 = Trash2; readonly Trash2 = Trash2;
readonly Sparkles = Sparkles; readonly Sparkles = Sparkles;
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
selectedIcon: string | null = null;
/** État drawer chat IA (b5.7 — intégration Campagne). */ /** État drawer chat IA (b5.7 — intégration Campagne). */
chatOpen = false; chatOpen = false;
@@ -122,6 +126,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
this.loreId = loreId; this.loreId = loreId;
this.availablePages = pages; this.availablePages = pages;
this.relatedPageIds = [...(arc.relatedPageIds ?? [])]; this.relatedPageIds = [...(arc.relatedPageIds ?? [])];
this.selectedIcon = arc.icon ?? null;
this.illustrationImageIds = [...(arc.illustrationImageIds ?? [])]; this.illustrationImageIds = [...(arc.illustrationImageIds ?? [])];
this.mapImageIds = [...(arc.mapImageIds ?? [])]; this.mapImageIds = [...(arc.mapImageIds ?? [])];
this.pageTitleService.set(arc.name); this.pageTitleService.set(arc.name);
@@ -167,7 +172,8 @@ export class ArcEditComponent implements OnInit, OnDestroy {
resolution: this.form.value.resolution, resolution: this.form.value.resolution,
relatedPageIds: this.relatedPageIds, relatedPageIds: this.relatedPageIds,
illustrationImageIds: this.illustrationImageIds, illustrationImageIds: this.illustrationImageIds,
mapImageIds: this.mapImageIds mapImageIds: this.mapImageIds,
icon: this.selectedIcon
}).subscribe({ }).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]), next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]),
error: () => console.error('Erreur lors de la sauvegarde') error: () => console.error('Erreur lors de la sauvegarde')

View File

@@ -2,7 +2,10 @@
<header class="view-header"> <header class="view-header">
<div> <div>
<h1>{{ arc.name }}</h1> <h1>
<lucide-icon *ngIf="arc.icon" [img]="resolveCampaignIcon(arc.icon)" [size]="22" class="title-icon"></lucide-icon>
{{ arc.name }}
</h1>
<p class="view-subtitle">Arc narratif</p> <p class="view-subtitle">Arc narratif</p>
</div> </div>
<div class="view-actions"> <div class="view-actions">

View File

@@ -4,6 +4,7 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs'; import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators'; import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular'; import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
import { resolveCampaignIcon } from '../campaign-icons';
import { CampaignService } from '../../services/campaign.service'; import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service'; import { CharacterService } from '../../services/character.service';
import { PageService } from '../../services/page.service'; import { PageService } from '../../services/page.service';
@@ -29,6 +30,7 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
export class ArcViewComponent implements OnInit, OnDestroy { export class ArcViewComponent implements OnInit, OnDestroy {
readonly Pencil = Pencil; readonly Pencil = Pencil;
readonly Trash2 = Trash2; readonly Trash2 = Trash2;
readonly resolveCampaignIcon = resolveCampaignIcon;
campaignId = ''; campaignId = '';
arcId = ''; arcId = '';

View File

@@ -0,0 +1,66 @@
import {
Bookmark,
Theater, Drama, Compass, Flag, Calendar, Map, Castle, Tent,
Swords, Skull, Crown, Heart, Eye, Footprints, Dice5, Hourglass,
Flame, Snowflake, Cloud, Sun, Moon, Star, Zap, Target,
Music, MessageCircle, Lock, Key as KeyIcon, Coins, Gift,
LucideIconData
} from 'lucide-angular';
// Type local equivalent a IconOption de lore-icons. Duplique volontairement
// pour eviter une dependance circulaire (lore-icons importe ce fichier).
export interface CampaignIconOption {
key: string;
icon: LucideIconData;
}
/**
* Banque d'icones dediee aux entites narratives d'une campagne (arc, chapitre, scene).
*
* Pourquoi separe de LORE_ICON_OPTIONS ? Les icones lore sont plutot orientees
* "objets de monde" (chateau, foret, dragon...). Ici on est sur du sequencement
* narratif et de la mise en scene : ambiances, actes, decisions. Cles uniques
* (prefixees `c-` quand un nom existerait deja dans le lore) pour eviter les
* collisions avec LORE_ICON_OPTIONS — le resolver consulte les deux registres.
*/
export const CAMPAIGN_ICON_OPTIONS: CampaignIconOption[] = [
{ key: 'c-theater', icon: Theater },
{ key: 'c-drama', icon: Drama },
{ key: 'c-compass', icon: Compass },
{ key: 'c-flag', icon: Flag },
{ key: 'c-calendar', icon: Calendar },
{ key: 'c-map', icon: Map },
{ key: 'c-castle', icon: Castle },
{ key: 'c-tent', icon: Tent },
{ key: 'c-swords', icon: Swords },
{ key: 'c-skull', icon: Skull },
{ key: 'c-crown', icon: Crown },
{ key: 'c-heart', icon: Heart },
{ key: 'c-eye', icon: Eye },
{ key: 'c-footprints', icon: Footprints },
{ key: 'c-dice', icon: Dice5 },
{ key: 'c-hourglass', icon: Hourglass },
{ key: 'c-flame', icon: Flame },
{ key: 'c-snowflake', icon: Snowflake },
{ key: 'c-cloud', icon: Cloud },
{ key: 'c-sun', icon: Sun },
{ key: 'c-moon', icon: Moon },
{ key: 'c-star', icon: Star },
{ key: 'c-zap', icon: Zap },
{ key: 'c-target', icon: Target },
{ key: 'c-music', icon: Music },
{ key: 'c-message', icon: MessageCircle },
{ key: 'c-lock', icon: Lock },
{ key: 'c-key', icon: KeyIcon },
{ key: 'c-coins', icon: Coins },
{ key: 'c-gift', icon: Gift },
];
/** Icone par defaut quand une entite narrative n'en a pas. */
export const DEFAULT_CAMPAIGN_ICON: LucideIconData = Bookmark;
/** Resolveur dedie. Prefere passer par `resolveIcon` dans lore-icons qui consulte les deux. */
export function resolveCampaignIcon(key: string | null | undefined): LucideIconData {
if (!key) return DEFAULT_CAMPAIGN_ICON;
return CAMPAIGN_ICON_OPTIONS.find(o => o.key === key)?.icon ?? DEFAULT_CAMPAIGN_ICON;
}

View File

@@ -109,11 +109,13 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
const sceneItems: TreeItem[] = sortedScenes.map(sc => ({ const sceneItems: TreeItem[] = sortedScenes.map(sc => ({
id: `scene-${sc.id}`, id: `scene-${sc.id}`,
label: sc.name, label: sc.name,
iconKey: sc.icon ?? undefined,
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}/scenes/${sc.id}` route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}/scenes/${sc.id}`
})); }));
return { return {
id: `chapter-${ch.id}`, id: `chapter-${ch.id}`,
label: ch.name, label: ch.name,
iconKey: ch.icon ?? undefined,
children: sceneItems, children: sceneItems,
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}`, route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}`,
createActions: [{ createActions: [{
@@ -127,6 +129,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
return { return {
id: `arc-${arc.id}`, id: `arc-${arc.id}`,
label: arc.name, label: arc.name,
iconKey: arc.icon ?? undefined,
children: chapterItems, children: chapterItems,
route: `/campaigns/${campaignId}/arcs/${arc.id}`, route: `/campaigns/${campaignId}/arcs/${arc.id}`,
sectionHeaderBefore: idx === 0 ? 'Narration' : undefined, sectionHeaderBefore: idx === 0 ? 'Narration' : undefined,

View File

@@ -28,6 +28,11 @@
</textarea> </textarea>
</div> </div>
<div class="field">
<label>Icône</label>
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
</div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid"> <button type="submit" class="btn-primary" [disabled]="form.invalid">
Créer le chapitre Créer le chapitre

View File

@@ -9,6 +9,8 @@ import { CharacterService } from '../../services/character.service';
import { LayoutService, GlobalItem } from '../../services/layout.service'; import { LayoutService, GlobalItem } from '../../services/layout.service';
import { Campaign } from '../../services/campaign.model'; import { Campaign } from '../../services/campaign.model';
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper'; 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. * Écran de création d'un nouveau chapitre rattaché à un arc.
@@ -17,11 +19,14 @@ import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper
@Component({ @Component({
selector: 'app-chapter-create', selector: 'app-chapter-create',
standalone: true, standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule], imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, IconPickerComponent],
templateUrl: './chapter-create.component.html', templateUrl: './chapter-create.component.html',
styleUrls: ['./chapter-create.component.scss'] styleUrls: ['./chapter-create.component.scss']
}) })
export class ChapterCreateComponent implements OnInit, OnDestroy { export class ChapterCreateComponent implements OnInit, OnDestroy {
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
selectedIcon: string | null = null;
form: FormGroup; form: FormGroup;
campaignId = ''; campaignId = '';
arcId = ''; arcId = '';
@@ -82,7 +87,8 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
name: this.form.value.name, name: this.form.value.name,
description: this.form.value.description, description: this.form.value.description,
arcId: this.arcId, arcId: this.arcId,
order: this.existingChapterCount + 1 order: this.existingChapterCount + 1,
icon: this.selectedIcon
}).subscribe({ }).subscribe({
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', created.id]), next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', created.id]),
error: () => console.error('Erreur lors de la création du chapitre') error: () => console.error('Erreur lors de la création du chapitre')

View File

@@ -13,6 +13,14 @@
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon> <lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA Assistant IA
</button> </button>
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
<button type="button" class="btn-danger" (click)="delete()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
<button type="button" class="btn-primary" (click)="submit()" [disabled]="form.invalid">
Sauvegarder
</button>
</div> </div>
</div> </div>
@@ -63,6 +71,11 @@
</textarea> </textarea>
</div> </div>
<div class="field">
<label>Icône</label>
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
</div>
<div class="field"> <div class="field">
<label for="chapter-edit-gm-notes">Notes du Maître de Jeu</label> <label for="chapter-edit-gm-notes">Notes du Maître de Jeu</label>
<textarea <textarea
@@ -116,17 +129,6 @@
</small> </small>
</div> </div>
<div class="form-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
Sauvegarder
</button>
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
<button type="button" class="btn-danger" (click)="delete()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</form> </form>
</div> </div>

View File

@@ -16,6 +16,8 @@ import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component'; import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component'; import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.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. * Écran de détail/modification d'un Chapitre.
@@ -27,13 +29,15 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
@Component({ @Component({
selector: 'app-chapter-edit', selector: 'app-chapter-edit',
standalone: true, standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent], imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent],
templateUrl: './chapter-edit.component.html', templateUrl: './chapter-edit.component.html',
styleUrls: ['./chapter-edit.component.scss'] styleUrls: ['./chapter-edit.component.scss']
}) })
export class ChapterEditComponent implements OnInit, OnDestroy { export class ChapterEditComponent implements OnInit, OnDestroy {
readonly Trash2 = Trash2; readonly Trash2 = Trash2;
readonly Sparkles = Sparkles; readonly Sparkles = Sparkles;
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
selectedIcon: string | null = null;
/** État drawer chat IA (b5.7 — intégration Campagne). */ /** État drawer chat IA (b5.7 — intégration Campagne). */
chatOpen = false; chatOpen = false;
@@ -113,6 +117,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
this.loreId = loreId; this.loreId = loreId;
this.availablePages = pages; this.availablePages = pages;
this.relatedPageIds = [...(chapter.relatedPageIds ?? [])]; this.relatedPageIds = [...(chapter.relatedPageIds ?? [])];
this.selectedIcon = chapter.icon ?? null;
this.illustrationImageIds = [...(chapter.illustrationImageIds ?? [])]; this.illustrationImageIds = [...(chapter.illustrationImageIds ?? [])];
this.mapImageIds = [...(chapter.mapImageIds ?? [])]; this.mapImageIds = [...(chapter.mapImageIds ?? [])];
this.form.patchValue({ this.form.patchValue({
@@ -153,7 +158,8 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
narrativeStakes: this.form.value.narrativeStakes, narrativeStakes: this.form.value.narrativeStakes,
relatedPageIds: this.relatedPageIds, relatedPageIds: this.relatedPageIds,
illustrationImageIds: this.illustrationImageIds, illustrationImageIds: this.illustrationImageIds,
mapImageIds: this.mapImageIds mapImageIds: this.mapImageIds,
icon: this.selectedIcon
}).subscribe({ }).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]), next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]),
error: () => console.error('Erreur lors de la sauvegarde') error: () => console.error('Erreur lors de la sauvegarde')

View File

@@ -16,11 +16,16 @@
</div> </div>
<div class="graph-container" *ngIf="scenes.length > 0"> <div class="graph-container" *ngIf="scenes.length > 0">
<svg [attr.width]="svgWidth" [attr.height]="svgHeight" class="graph-svg"> <svg #svgEl
[attr.width]="svgWidth" [attr.height]="svgHeight"
class="graph-svg"
(pointermove)="onPointerMove($event)"
(pointerup)="onPointerUp($event)"
(pointercancel)="onPointerUp($event)">
<defs> <defs>
<marker id="arrowhead" viewBox="0 0 10 10" refX="9" refY="5" <marker id="arrowhead" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="7" markerHeight="7" orient="auto-start-reverse"> markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#6b7280" /> <path d="M 0 0 L 10 5 L 0 10 z" fill="#b8c0cc" />
</marker> </marker>
</defs> </defs>
@@ -28,20 +33,25 @@
<g class="edge" *ngFor="let edge of edges"> <g class="edge" *ngFor="let edge of edges">
<line [attr.x1]="edge.x1" [attr.y1]="edge.y1" <line [attr.x1]="edge.x1" [attr.y1]="edge.y1"
[attr.x2]="edge.x2" [attr.y2]="edge.y2" [attr.x2]="edge.x2" [attr.y2]="edge.y2"
stroke="#6b7280" stroke-width="2" stroke="#b8c0cc" stroke-width="2"
marker-end="url(#arrowhead)" /> marker-end="url(#arrowhead)" />
<text *ngIf="edge.label" <text *ngIf="edge.label"
[attr.x]="edge.labelX" [attr.x]="edge.labelX"
[attr.y]="edge.labelY" [attr.y]="edge.labelY"
text-anchor="middle" text-anchor="middle"
class="edge-label"> class="edge-label"
[class.dragging]="draggingLabelKey === edge.key"
(pointerdown)="onLabelPointerDown($event, edge)">
{{ edge.label }} {{ edge.label }}
</text> </text>
</g> </g>
</g> </g>
<g class="nodes"> <g class="nodes">
<g class="node" *ngFor="let node of nodes" (click)="openScene(node.id)"> <g class="node"
[class.dragging]="draggingId === node.id"
*ngFor="let node of nodes"
(pointerdown)="onPointerDown($event, node)">
<title>{{ node.name }}</title> <title>{{ node.name }}</title>
<rect [attr.x]="node.x" [attr.y]="node.y" <rect [attr.x]="node.x" [attr.y]="node.y"
[attr.width]="NODE_WIDTH" [attr.height]="NODE_HEIGHT" [attr.width]="NODE_WIDTH" [attr.height]="NODE_HEIGHT"
@@ -57,7 +67,7 @@
</svg> </svg>
<small class="graph-hint"> <small class="graph-hint">
💡 Cliquez sur une scène pour l'ouvrir. Les scènes non reliées au point d'entrée (scène d'ordre 1) apparaissent en bas. 💡 Cliquez sur une scène pour l'ouvrir, ou glissez-la pour réorganiser la carte. Les scènes non reliées au point d'entrée (scène d'ordre 1) apparaissent en bas.
</small> </small>
</div> </div>

View File

@@ -11,7 +11,7 @@
margin-bottom: 2rem; margin-bottom: 2rem;
.subtitle { .subtitle {
color: #6b7280; color: #9ca3af;
font-size: 0.9rem; font-size: 0.9rem;
margin: 0.25rem 0 0; margin: 0.25rem 0 0;
} }
@@ -20,15 +20,17 @@
.graph-empty { .graph-empty {
padding: 2rem; padding: 2rem;
text-align: center; text-align: center;
color: #6b7280; color: #9ca3af;
background: #f9fafb; background: #14141f;
border-radius: 8px; border-radius: 8px;
border: 1px dashed #d1d5db; border: 1px dashed #374151;
} }
.graph-container { .graph-container {
background: #fafafa; // Fond legerement plus sombre que la couleur des noeuds : creuse l'image
border: 1px solid #e5e7eb; // sans aller jusqu'au noir pur (qui « brulerait » par contraste).
background: #0d0d18;
border: 1px solid #374151;
border-radius: 12px; border-radius: 12px;
padding: 20px; padding: 20px;
overflow: auto; overflow: auto;
@@ -41,14 +43,27 @@
.graph-svg { .graph-svg {
display: block; display: block;
max-width: 100%; max-width: 100%;
// Empeche le browser de clipper le contenu qui depasserait le viewport SVG
// pendant un drag — le scroll du conteneur prend le relais.
overflow: visible;
// Évite que le navigateur intercepte le drag pour faire de la sélection texte
// ou du panning natif sur les nœuds.
touch-action: none;
user-select: none;
} }
.node { .node {
cursor: pointer; cursor: grab;
// Ombre portee douce pour detacher chaque noeud du fond. Faible alpha pour
// rester subtil sur fond sombre, large diffusion pour rester organique.
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.5));
.node-box { .node-box {
fill: #ffffff; // Indigo desature : reprend la palette accent (#6c63ff) en version assombrie
stroke: #1f2937; // pour donner du caractere aux noeuds sans saturer la vue. Bordure assortie
// un peu plus claire pour bien dessiner le contour.
fill: #1f1d3a;
stroke: #4f4a7a;
stroke-width: 2; stroke-width: 2;
transition: fill 0.15s ease, stroke 0.15s ease; transition: fill 0.15s ease, stroke 0.15s ease;
} }
@@ -56,31 +71,47 @@
.node-label { .node-label {
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 500; font-weight: 500;
fill: #1f2937; fill: #f3f4f6;
pointer-events: none; pointer-events: none;
} }
&:hover .node-box { &:hover .node-box {
fill: #eef2ff; fill: #2c2952;
stroke: #4f46e5; stroke: #8b80ff;
}
&.dragging {
cursor: grabbing;
.node-box {
fill: #2c2952;
stroke: #8b80ff;
filter: drop-shadow(0 4px 10px rgba(108, 99, 255, 0.35));
}
} }
} }
.edge-label { .edge-label {
font-size: 0.75rem; font-size: 0.75rem;
fill: #4b5563; fill: #e5e7eb;
font-style: italic; font-style: italic;
// Halo blanc autour du texte pour garantir la lisibilité même s'il passe cursor: grab;
// sur une ligne ou un autre élément. // Halo sombre autour du texte pour rester lisible quand un label passe
// par-dessus une arête ou un autre nœud. Aligne sur la couleur du fond.
paint-order: stroke; paint-order: stroke;
stroke: #fafafa; stroke: #0d0d18;
stroke-width: 3px; stroke-width: 4px;
stroke-linejoin: round; stroke-linejoin: round;
&:hover { fill: #ffffff; }
&.dragging {
cursor: grabbing;
fill: #ffffff;
}
} }
.graph-hint { .graph-hint {
display: block; display: block;
margin-top: 1rem; margin-top: 1rem;
color: #6b7280; color: #9ca3af;
font-size: 0.85rem; font-size: 0.85rem;
} }

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy, ElementRef, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin } from 'rxjs'; import { forkJoin } from 'rxjs';
@@ -11,7 +11,7 @@ import { Campaign, Chapter, Scene } from '../../services/campaign.model';
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper'; import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
interface GraphNode { id: string; name: string; displayName: string; x: number; y: number; } interface GraphNode { id: string; name: string; displayName: string; x: number; y: number; }
interface GraphEdge { label: string; x1: number; y1: number; x2: number; y2: number; labelX: number; labelY: number; } interface GraphEdge { key: string; label: string; x1: number; y1: number; x2: number; y2: number; labelX: number; labelY: number; }
/** /**
* Vue graphique d'un chapitre : organigramme des scènes et branches narratives. * Vue graphique d'un chapitre : organigramme des scènes et branches narratives.
@@ -45,6 +45,24 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
svgWidth = 600; svgWidth = 600;
svgHeight = 400; svgHeight = 400;
@ViewChild('svgEl') svgEl?: ElementRef<SVGSVGElement>;
// Etat de drag : id du noeud manipule, offset entre le pointeur et le coin
// haut-gauche du noeud (en coords SVG), et flag indiquant qu'un mouvement
// significatif a eu lieu (pour distinguer clic vs glisser).
draggingId: string | null = null;
draggingLabelKey: string | null = null;
private dragOffsetX = 0;
private dragOffsetY = 0;
private dragMoved = false;
private readonly DRAG_THRESHOLD = 4;
// Decalage manuel applique a chaque label d'arete, indexe par cle stable
// (sourceId|targetId|branchIdx). Persiste a travers les recalculs d'aretes
// pour que le label suive son arete quand on deplace un noeud, tout en
// conservant le repositionnement manuel de l'utilisateur.
private labelOffsets = new Map<string, { dx: number; dy: number }>();
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
@@ -153,7 +171,18 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
}); });
} }
const nodeMap = new Map(nodes.map(n => [n.id, n])); this.nodes = nodes;
this.recomputeEdges();
this.svgWidth = Math.max(rowWidth + 40, 600);
this.svgHeight = (orphanLevel + 1) * (this.NODE_HEIGHT + this.V_SPACING) + 40;
}
/**
* Recalcule la geometrie des aretes a partir des positions courantes des noeuds.
* Appele apres le layout initial et apres chaque deplacement manuel d'un noeud.
*/
private recomputeEdges(): void {
const nodeMap = new Map(this.nodes.map(n => [n.id, n]));
const edges: GraphEdge[] = []; const edges: GraphEdge[] = [];
for (const scene of this.scenes) { for (const scene of this.scenes) {
const from = nodeMap.get(scene.id!); const from = nodeMap.get(scene.id!);
@@ -171,19 +200,158 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
const y2 = to.y; const y2 = to.y;
// t ∈ [0.25, 0.55] : labels plutot pres de la source, echelonnes. // t ∈ [0.25, 0.55] : labels plutot pres de la source, echelonnes.
const t = count === 1 ? 0.5 : 0.25 + (idx / (count - 1)) * 0.3; const t = count === 1 ? 0.5 : 0.25 + (idx / (count - 1)) * 0.3;
const key = `${scene.id}|${b.targetSceneId}|${idx}`;
const offset = this.labelOffsets.get(key) ?? { dx: 0, dy: 0 };
edges.push({ edges.push({
key,
label: b.label, label: b.label,
x1, y1, x2, y2, x1, y1, x2, y2,
labelX: x1 + (x2 - x1) * t, labelX: x1 + (x2 - x1) * t + offset.dx,
labelY: y1 + (y2 - y1) * t - 4 labelY: y1 + (y2 - y1) * t - 4 + offset.dy
}); });
}); });
} }
this.nodes = nodes;
this.edges = edges; this.edges = edges;
this.svgWidth = Math.max(rowWidth + 40, 600); }
this.svgHeight = (orphanLevel + 1) * (this.NODE_HEIGHT + this.V_SPACING) + 40;
/**
* Convertit des coordonnees ecran (PointerEvent) en coordonnees SVG via la CTM
* inverse. Necessaire car le SVG peut etre redimensionne par max-width.
*/
private toSvgCoords(evt: PointerEvent): { x: number; y: number } {
const svg = this.svgEl?.nativeElement;
if (!svg) return { x: evt.clientX, y: evt.clientY };
const pt = svg.createSVGPoint();
pt.x = evt.clientX;
pt.y = evt.clientY;
const ctm = svg.getScreenCTM();
if (!ctm) return { x: evt.clientX, y: evt.clientY };
const local = pt.matrixTransform(ctm.inverse());
return { x: local.x, y: local.y };
}
onPointerDown(evt: PointerEvent, node: GraphNode): void {
// Bouton gauche uniquement.
if (evt.button !== 0) return;
evt.preventDefault();
const { x, y } = this.toSvgCoords(evt);
this.draggingId = node.id;
this.dragOffsetX = x - node.x;
this.dragOffsetY = y - node.y;
this.dragMoved = false;
(evt.target as Element).setPointerCapture?.(evt.pointerId);
}
onPointerMove(evt: PointerEvent): void {
const { x, y } = this.toSvgCoords(evt);
if (this.draggingLabelKey) {
const edge = this.edges.find(e => e.key === this.draggingLabelKey);
if (!edge) return;
const newX = x - this.dragOffsetX;
const newY = y - this.dragOffsetY;
if (!this.dragMoved && Math.hypot(newX - edge.labelX, newY - edge.labelY) < this.DRAG_THRESHOLD) return;
this.dragMoved = true;
// Recalcule la position automatique courante puis stocke la difference,
// pour que l'offset reste valable meme apres deplacement d'un noeud.
const auto = this.autoLabelPosition(edge.key);
if (auto) {
this.labelOffsets.set(edge.key, { dx: newX - auto.x, dy: newY - auto.y });
}
edge.labelX = newX;
edge.labelY = newY;
return;
}
if (!this.draggingId) return;
const node = this.nodes.find(n => n.id === this.draggingId);
if (!node) return;
// Empeche le noeud de partir en coordonnees negatives : sinon il sort
// du viewport SVG et se fait clipper par le navigateur (le SVG a
// overflow: hidden par defaut quand on lui donne width/height explicites).
const newX = Math.max(0, x - this.dragOffsetX);
const newY = Math.max(0, y - this.dragOffsetY);
if (!this.dragMoved) {
const dx = newX - node.x;
const dy = newY - node.y;
if (Math.hypot(dx, dy) >= this.DRAG_THRESHOLD) this.dragMoved = true;
else return;
}
node.x = newX;
node.y = newY;
this.recomputeEdges();
this.fitSvgToNodes();
}
/**
* Recalcule la position "auto" (sans offset manuel) du label d'une arete
* a partir de sa cle. Utilise pour deriver le delta a stocker pendant le drag.
*/
private autoLabelPosition(key: string): { x: number; y: number } | null {
const [sourceId, targetId, idxStr] = key.split('|');
const idx = Number(idxStr);
const scene = this.scenes.find(s => s.id === sourceId);
if (!scene?.branches) return null;
const siblings = scene.branches.filter(b => this.nodes.some(n => n.id === b.targetSceneId));
const count = siblings.length;
if (idx >= count) return null;
const from = this.nodes.find(n => n.id === sourceId);
const to = this.nodes.find(n => n.id === targetId);
if (!from || !to) return null;
const x1 = from.x + this.NODE_WIDTH / 2;
const y1 = from.y + this.NODE_HEIGHT;
const x2 = to.x + this.NODE_WIDTH / 2;
const y2 = to.y;
const t = count === 1 ? 0.5 : 0.25 + (idx / (count - 1)) * 0.3;
return { x: x1 + (x2 - x1) * t, y: y1 + (y2 - y1) * t - 4 };
}
onLabelPointerDown(evt: PointerEvent, edge: GraphEdge): void {
if (evt.button !== 0) return;
// Empeche l'event de remonter au <g class="node"> ou au svg, sinon on
// declencherait aussi un drag de noeud.
evt.stopPropagation();
evt.preventDefault();
const { x, y } = this.toSvgCoords(evt);
this.draggingLabelKey = edge.key;
this.dragOffsetX = x - edge.labelX;
this.dragOffsetY = y - edge.labelY;
this.dragMoved = false;
(evt.target as Element).setPointerCapture?.(evt.pointerId);
}
/**
* Agrandit le SVG si un noeud s'approche du bord droit ou bas, pour eviter
* que le contenu deplace soit rogne. On ne reduit jamais en-dessous de la
* taille initiale du layout pour rester stable visuellement.
*/
private fitSvgToNodes(): void {
const margin = 40;
let maxX = 600;
let maxY = 200;
for (const n of this.nodes) {
if (n.x + this.NODE_WIDTH + margin > maxX) maxX = n.x + this.NODE_WIDTH + margin;
if (n.y + this.NODE_HEIGHT + margin > maxY) maxY = n.y + this.NODE_HEIGHT + margin;
}
if (maxX > this.svgWidth) this.svgWidth = maxX;
if (maxY > this.svgHeight) this.svgHeight = maxY;
}
onPointerUp(evt: PointerEvent): void {
if (this.draggingLabelKey) {
this.draggingLabelKey = null;
this.dragMoved = false;
(evt.target as Element).releasePointerCapture?.(evt.pointerId);
return;
}
if (!this.draggingId) return;
const id = this.draggingId;
const moved = this.dragMoved;
this.draggingId = null;
this.dragMoved = false;
(evt.target as Element).releasePointerCapture?.(evt.pointerId);
// Si le pointeur n'a pas reellement bouge, on traite comme un clic d'ouverture.
if (!moved) this.openScene(id);
} }
private truncate(text: string): string { private truncate(text: string): string {

View File

@@ -2,7 +2,10 @@
<header class="view-header"> <header class="view-header">
<div> <div>
<h1>{{ chapter.name }}</h1> <h1>
<lucide-icon *ngIf="chapter.icon" [img]="resolveCampaignIcon(chapter.icon)" [size]="22" class="title-icon"></lucide-icon>
{{ chapter.name }}
</h1>
<p class="view-subtitle">Chapitre</p> <p class="view-subtitle">Chapitre</p>
</div> </div>
<div class="view-actions"> <div class="view-actions">

View File

@@ -4,6 +4,7 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs'; import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators'; import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Pencil, Network, Trash2 } from 'lucide-angular'; import { LucideAngularModule, Pencil, Network, Trash2 } from 'lucide-angular';
import { resolveCampaignIcon } from '../campaign-icons';
import { CampaignService } from '../../services/campaign.service'; import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service'; import { CharacterService } from '../../services/character.service';
import { PageService } from '../../services/page.service'; import { PageService } from '../../services/page.service';
@@ -29,6 +30,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
readonly Pencil = Pencil; readonly Pencil = Pencil;
readonly Network = Network; readonly Network = Network;
readonly Trash2 = Trash2; readonly Trash2 = Trash2;
readonly resolveCampaignIcon = resolveCampaignIcon;
campaignId = ''; campaignId = '';
arcId = ''; arcId = '';

View File

@@ -28,6 +28,11 @@
</textarea> </textarea>
</div> </div>
<div class="field">
<label>Icône</label>
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
</div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid"> <button type="submit" class="btn-primary" [disabled]="form.invalid">
Créer la scène Créer la scène

View File

@@ -9,6 +9,8 @@ import { CharacterService } from '../../services/character.service';
import { LayoutService, GlobalItem } from '../../services/layout.service'; import { LayoutService, GlobalItem } from '../../services/layout.service';
import { Campaign } from '../../services/campaign.model'; import { Campaign } from '../../services/campaign.model';
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper'; 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. * Écran de création d'une nouvelle scène rattachée à un chapitre.
@@ -17,11 +19,14 @@ import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper
@Component({ @Component({
selector: 'app-scene-create', selector: 'app-scene-create',
standalone: true, standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule], imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, IconPickerComponent],
templateUrl: './scene-create.component.html', templateUrl: './scene-create.component.html',
styleUrls: ['./scene-create.component.scss'] styleUrls: ['./scene-create.component.scss']
}) })
export class SceneCreateComponent implements OnInit, OnDestroy { export class SceneCreateComponent implements OnInit, OnDestroy {
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
selectedIcon: string | null = null;
form: FormGroup; form: FormGroup;
campaignId = ''; campaignId = '';
arcId = ''; arcId = '';
@@ -84,7 +89,8 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
name: this.form.value.name, name: this.form.value.name,
description: this.form.value.description, description: this.form.value.description,
chapterId: this.chapterId, chapterId: this.chapterId,
order: this.existingSceneCount + 1 order: this.existingSceneCount + 1,
icon: this.selectedIcon
}).subscribe({ }).subscribe({
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', created.id]), next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', created.id]),
error: () => console.error('Erreur lors de la création de la scène') error: () => console.error('Erreur lors de la création de la scène')

View File

@@ -13,6 +13,14 @@
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon> <lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA Assistant IA
</button> </button>
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
<button type="button" class="btn-danger" (click)="delete()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
<button type="button" class="btn-primary" (click)="submit()" [disabled]="form.invalid">
Sauvegarder
</button>
</div> </div>
</div> </div>
@@ -63,6 +71,11 @@
</textarea> </textarea>
</div> </div>
<div class="field">
<label>Icône</label>
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
</div>
<!-- Section : Contexte et ambiance --> <!-- Section : Contexte et ambiance -->
<app-expandable-section title="Contexte et ambiance" icon="📍" [initiallyOpen]="true"> <app-expandable-section title="Contexte et ambiance" icon="📍" [initiallyOpen]="true">
<div class="field-row"> <div class="field-row">
@@ -218,17 +231,6 @@
</small> </small>
</div> </div>
<div class="form-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
Sauvegarder
</button>
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
<button type="button" class="btn-danger" (click)="delete()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</form> </form>
</div> </div>

View File

@@ -17,6 +17,8 @@ import { ExpandableSectionComponent } from '../../shared/expandable-section/expa
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component'; import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component'; import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.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. * Écran de détail/modification d'une Scène.
@@ -25,13 +27,15 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
@Component({ @Component({
selector: 'app-scene-edit', selector: 'app-scene-edit',
standalone: true, standalone: true,
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent], imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent],
templateUrl: './scene-edit.component.html', templateUrl: './scene-edit.component.html',
styleUrls: ['./scene-edit.component.scss'] styleUrls: ['./scene-edit.component.scss']
}) })
export class SceneEditComponent implements OnInit, OnDestroy { export class SceneEditComponent implements OnInit, OnDestroy {
readonly Trash2 = Trash2; readonly Trash2 = Trash2;
readonly Sparkles = Sparkles; readonly Sparkles = Sparkles;
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
selectedIcon: string | null = null;
/** État drawer chat IA (b5.7 — intégration Campagne). */ /** État drawer chat IA (b5.7 — intégration Campagne). */
chatOpen = false; chatOpen = false;
@@ -131,6 +135,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
this.loreId = loreId; this.loreId = loreId;
this.availablePages = pages; this.availablePages = pages;
this.relatedPageIds = [...(scene.relatedPageIds ?? [])]; this.relatedPageIds = [...(scene.relatedPageIds ?? [])];
this.selectedIcon = scene.icon ?? null;
this.illustrationImageIds = [...(scene.illustrationImageIds ?? [])]; this.illustrationImageIds = [...(scene.illustrationImageIds ?? [])];
this.mapImageIds = [...(scene.mapImageIds ?? [])]; this.mapImageIds = [...(scene.mapImageIds ?? [])];
this.siblingScenes = chapterScenes.filter(s => s.id !== this.sceneId); this.siblingScenes = chapterScenes.filter(s => s.id !== this.sceneId);
@@ -184,7 +189,8 @@ export class SceneEditComponent implements OnInit, OnDestroy {
relatedPageIds: this.relatedPageIds, relatedPageIds: this.relatedPageIds,
illustrationImageIds: this.illustrationImageIds, illustrationImageIds: this.illustrationImageIds,
mapImageIds: this.mapImageIds, mapImageIds: this.mapImageIds,
branches: this.branches branches: this.branches,
icon: this.selectedIcon
}).subscribe({ }).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', this.sceneId]), next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', this.sceneId]),
error: () => console.error('Erreur lors de la sauvegarde') error: () => console.error('Erreur lors de la sauvegarde')

View File

@@ -2,7 +2,10 @@
<header class="view-header"> <header class="view-header">
<div> <div>
<h1>{{ scene.name }}</h1> <h1>
<lucide-icon *ngIf="scene.icon" [img]="resolveCampaignIcon(scene.icon)" [size]="22" class="title-icon"></lucide-icon>
{{ scene.name }}
</h1>
<p class="view-subtitle">Scène</p> <p class="view-subtitle">Scène</p>
</div> </div>
<div class="view-actions"> <div class="view-actions">

View File

@@ -4,6 +4,7 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs'; import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators'; import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular'; import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
import { resolveCampaignIcon } from '../campaign-icons';
import { CampaignService } from '../../services/campaign.service'; import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service'; import { CharacterService } from '../../services/character.service';
import { PageService } from '../../services/page.service'; import { PageService } from '../../services/page.service';
@@ -28,6 +29,7 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
export class SceneViewComponent implements OnInit, OnDestroy { export class SceneViewComponent implements OnInit, OnDestroy {
readonly Pencil = Pencil; readonly Pencil = Pencil;
readonly Trash2 = Trash2; readonly Trash2 = Trash2;
readonly resolveCampaignIcon = resolveCampaignIcon;
campaignId = ''; campaignId = '';
arcId = ''; arcId = '';

View File

@@ -0,0 +1,65 @@
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { catchError, throwError } from 'rxjs';
/**
* Detecte la perte de session demo (orchestrateur) via les codes 401/502 sur
* les appels /api/*, affiche un overlay puis force un rechargement de la page.
* Le reload renvoie l'utilisateur sur la page "Preparation" pour creer une
* nouvelle session sans qu'il ait a faire Ctrl+Shift+R.
*
* Cet interceptor est inerte en mode normal (non-demo) : si le backend natif
* renvoie un 401 legitime, ca declenche aussi le reload, ce qui est sans
* consequence puisqu'aucun flux d'auth utilisateur n'existe encore cote app.
*/
// Module-level flag : evite de declencher overlay + reload plusieurs fois si
// plusieurs appels echouent en parallele juste apres l'expiration.
let alreadyTriggered = false;
export const sessionExpiredInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
catchError((err) => {
const isApiCall = req.url.includes('/api/');
const isSessionLoss =
err instanceof HttpErrorResponse && (err.status === 401 || err.status === 502);
if (isApiCall && isSessionLoss && !alreadyTriggered) {
alreadyTriggered = true;
showExpiredOverlay();
setTimeout(() => window.location.reload(), 2500);
}
return throwError(() => err);
})
);
};
function showExpiredOverlay(): void {
const overlay = document.createElement('div');
overlay.setAttribute('data-session-expired', 'true');
overlay.style.cssText = [
'position:fixed', 'inset:0',
'background:rgba(26,22,37,0.96)',
'color:#e4def5',
'display:flex', 'flex-direction:column',
'align-items:center', 'justify-content:center',
'gap:1rem',
'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
'z-index:99999',
'text-align:center', 'padding:2rem',
].join(';');
overlay.innerHTML = `
<div style="font-size:1.5rem;color:#b794f4;">✦ Votre session démo a expiré</div>
<div style="color:#aaa0c5;max-width:420px;line-height:1.5;">
Une nouvelle session va être préparée automatiquement.<br>
Vos données précédentes ne sont pas conservées.
</div>
<div style="
width:32px;height:32px;margin-top:0.5rem;
border:3px solid rgba(183,148,244,0.2);
border-top-color:#b794f4;border-radius:50%;
animation:sex-spin 1s linear infinite;
"></div>
<style>@keyframes sex-spin{to{transform:rotate(360deg)}}</style>
`;
document.body.appendChild(overlay);
}

View File

@@ -4,6 +4,7 @@ import {
BookOpen, Scroll, Wand2, Sparkles, TreePine, Mountain, BookOpen, Scroll, Wand2, Sparkles, TreePine, Mountain,
Ship, Flame, Star, Moon, Key, Globe, Compass, LucideIconData Ship, Flame, Star, Moon, Key, Globe, Compass, LucideIconData
} from 'lucide-angular'; } from 'lucide-angular';
import { CAMPAIGN_ICON_OPTIONS } from '../campaigns/campaign-icons';
/** /**
* Registre partagé d'icônes disponibles pour les dossiers (LoreNode). * Registre partagé d'icônes disponibles pour les dossiers (LoreNode).
@@ -46,8 +47,15 @@ export const LORE_ICON_OPTIONS: IconOption[] = [
/** Icône par défaut pour un dossier sans icône. */ /** Icône par défaut pour un dossier sans icône. */
export const DEFAULT_FOLDER_ICON: LucideIconData = Folder; export const DEFAULT_FOLDER_ICON: LucideIconData = Folder;
/** Résout une clé d'icône en LucideIconData. Fallback : icône dossier par défaut. */ /**
* Résout une clé d'icône en LucideIconData. Consulte LORE_ICON_OPTIONS puis
* CAMPAIGN_ICON_OPTIONS pour permettre à la sidebar partagée d'afficher
* indifféremment des icônes de dossiers (lore) ou d'arcs/chapitres/scènes
* (campagne). Fallback : icône dossier par défaut.
*/
export function resolveIcon(key: string | null | undefined): LucideIconData { export function resolveIcon(key: string | null | undefined): LucideIconData {
if (!key) return DEFAULT_FOLDER_ICON; if (!key) return DEFAULT_FOLDER_ICON;
return LORE_ICON_OPTIONS.find(o => o.key === key)?.icon ?? DEFAULT_FOLDER_ICON; const loreMatch = LORE_ICON_OPTIONS.find(o => o.key === key);
if (loreMatch) return loreMatch.icon;
return CAMPAIGN_ICON_OPTIONS.find(o => o.key === key)?.icon ?? DEFAULT_FOLDER_ICON;
} }

View File

@@ -42,7 +42,8 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
*/ */
fields: TemplateField[] = [ fields: TemplateField[] = [
{ name: 'Nom', type: 'TEXT' }, { name: 'Nom', type: 'TEXT' },
{ name: 'Description', type: 'TEXT' } { name: 'Description', type: 'TEXT' },
{ name: 'Illustration', type: 'IMAGE', layout: 'GALLERY' }
]; ];
/** Valeur courante de l'input d'ajout de champ (non binding direct pour reset facile). */ /** Valeur courante de l'input d'ajout de champ (non binding direct pour reset facile). */
newFieldName = ''; newFieldName = '';

View File

@@ -103,7 +103,7 @@
<option value="TEXT">Texte</option> <option value="TEXT">Texte</option>
<option value="IMAGE">Image</option> <option value="IMAGE">Image</option>
</select> </select>
<button type="button" class="btn-add" (click)="addField()"> <button type="button" class="btn-add" (click)="addField()" title="Ajouter le champ">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> <lucide-icon [img]="Plus" [size]="14"></lucide-icon>
</button> </button>
</div> </div>

View File

@@ -198,18 +198,7 @@
&::placeholder { color: #6b7280; } &::placeholder { color: #6b7280; }
} }
&.add-row { &.add-row { margin-top: 0.5rem; }
margin-top: 0.25rem;
border: 1px dashed #2a2a3d;
border-radius: 6px;
padding: 0;
input {
border: none;
background: transparent;
&:focus { border: none; }
}
}
.reorder-stack { .reorder-stack {
display: flex; display: flex;

View File

@@ -29,6 +29,9 @@ export interface Arc {
order?: number; order?: number;
chapterCount?: number; chapterCount?: number;
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS). */
icon?: string | null;
// Champs narratifs enrichis // Champs narratifs enrichis
themes?: string; themes?: string;
stakes?: string; stakes?: string;
@@ -52,6 +55,7 @@ export interface ArcCreate {
description?: string; description?: string;
campaignId: string; campaignId: string;
order: number; order: number;
icon?: string | null;
themes?: string; themes?: string;
stakes?: string; stakes?: string;
@@ -70,6 +74,7 @@ export interface Chapter {
description?: string; description?: string;
arcId: string; arcId: string;
order?: number; order?: number;
icon?: string | null;
// Champs narratifs enrichis // Champs narratifs enrichis
gmNotes?: string; gmNotes?: string;
@@ -86,6 +91,7 @@ export interface ChapterCreate {
description?: string; description?: string;
arcId: string; arcId: string;
order: number; order: number;
icon?: string | null;
gmNotes?: string; gmNotes?: string;
playerObjectives?: string; playerObjectives?: string;
@@ -112,6 +118,7 @@ export interface Scene {
description?: string; // = Description courte dans l'UI description?: string; // = Description courte dans l'UI
chapterId: string; chapterId: string;
order?: number; order?: number;
icon?: string | null;
// Champs narratifs enrichis // Champs narratifs enrichis
location?: string; location?: string;
@@ -136,6 +143,7 @@ export interface SceneCreate {
description?: string; description?: string;
chapterId: string; chapterId: string;
order: number; order: number;
icon?: string | null;
location?: string; location?: string;
timing?: string; timing?: string;

View File

@@ -0,0 +1,31 @@
.icon-grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
background: #1f2937;
border: 1px solid #374151;
border-radius: 8px;
padding: 0.75rem;
}
.icon-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: #9ca3af;
cursor: pointer;
transition: all 0.15s;
&:hover { background: #374151; color: white; }
&.selected {
background: #1e1b4b;
border-color: #6c63ff;
color: #a5b4fc;
}
}

View File

@@ -0,0 +1,54 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, LucideIconData } from 'lucide-angular';
export interface IconPickerOption {
key: string;
icon: LucideIconData;
}
/**
* Petit composant reutilisable : grille d'icones cliquables avec selection unique.
* Utilise par les formulaires de creation / edition d'arcs, chapitres et scenes
* (ainsi que potentiellement les dossiers du Lore plus tard).
*
* Usage :
* <app-icon-picker [options]="iconOptions" [(selected)]="selectedIcon"></app-icon-picker>
*
* Le composant est purement presentationnel : il n'interroge pas le registre
* d'icones lui-meme — l'appelant lui passe la banque a afficher.
*/
@Component({
selector: 'app-icon-picker',
standalone: true,
imports: [CommonModule, LucideAngularModule],
template: `
<div class="icon-grid">
<button
type="button"
class="icon-btn"
*ngFor="let option of options"
[class.selected]="selected === option.key"
[attr.aria-pressed]="selected === option.key"
[title]="option.key"
(click)="pick(option.key)">
<lucide-icon [img]="option.icon" [size]="18"></lucide-icon>
</button>
</div>
`,
styleUrls: ['./icon-picker.component.scss']
})
export class IconPickerComponent {
@Input() options: IconPickerOption[] = [];
@Input() selected: string | null = null;
/** Permet le binding two-way `[(selected)]`. */
@Output() selectedChange = new EventEmitter<string | null>();
/** Si true, recliquer sur l'icone actuellement selectionnee la deselectionne. */
@Input() allowDeselect = true;
pick(key: string): void {
const next = this.allowDeselect && this.selected === key ? null : key;
this.selected = next;
this.selectedChange.emit(next);
}
}

View File

@@ -2,9 +2,10 @@ import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component'; import { AppComponent } from './app/app.component';
import { PreloadAllModules, provideRouter, withPreloading } from '@angular/router'; import { PreloadAllModules, provideRouter, withPreloading } from '@angular/router';
import { routes } from './app/app.routes'; import { routes } from './app/app.routes';
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { APP_INITIALIZER } from '@angular/core'; import { APP_INITIALIZER } from '@angular/core';
import { ConfigService } from './app/services/config.service'; import { ConfigService } from './app/services/config.service';
import { sessionExpiredInterceptor } from './app/interceptors/session-expired.interceptor';
// withPreloading(PreloadAllModules) : une fois l'app initiale rendue, Angular // withPreloading(PreloadAllModules) : une fois l'app initiale rendue, Angular
// telecharge en arriere-plan tous les chunks lazy-loades. Consequence : la // telecharge en arriere-plan tous les chunks lazy-loades. Consequence : la
@@ -14,7 +15,7 @@ import { ConfigService } from './app/services/config.service';
bootstrapApplication(AppComponent, { bootstrapApplication(AppComponent, {
providers: [ providers: [
provideRouter(routes, withPreloading(PreloadAllModules)), provideRouter(routes, withPreloading(PreloadAllModules)),
provideHttpClient(), provideHttpClient(withInterceptors([sessionExpiredInterceptor])),
{ {
provide: APP_INITIALIZER, provide: APP_INITIALIZER,
useFactory: (config: ConfigService) => () => config.load(), useFactory: (config: ConfigService) => () => config.load(),

View File

@@ -35,6 +35,26 @@
color: white; color: white;
margin: 0 0 0.3rem; margin: 0 0 0.3rem;
line-height: 1.2; line-height: 1.2;
display: flex;
align-items: center;
gap: 0.6rem;
// Pastille violette autour de l'icone. <lucide-icon> est inline par
// defaut : sans inline-flex le SVG se cale en haut-gauche de la boite
// au lieu d'etre centre. On fixe aussi la taille pour que la pastille
// soit toujours carree quelle que soit l'icone.
.title-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
color: #a5b4fc;
background: #1e1b4b;
border: 1px solid #4338ca;
border-radius: 8px;
flex-shrink: 0;
}
} }
.view-subtitle { .view-subtitle {
color: #9ca3af; color: #9ca3af;