diff --git a/core/src/test/java/com/loremind/application/campaigncontext/CampaignServiceTest.java b/core/src/test/java/com/loremind/application/campaigncontext/CampaignServiceTest.java
index e6ccbb7..677f5a5 100644
--- a/core/src/test/java/com/loremind/application/campaigncontext/CampaignServiceTest.java
+++ b/core/src/test/java/com/loremind/application/campaigncontext/CampaignServiceTest.java
@@ -53,7 +53,10 @@ public class CampaignServiceTest {
"lore-123",
null
);
- when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign);
+ // Le repo renvoie la Campaign telle que passée — on teste la normalisation
+ // du loreId dans le service, pas le comportement du repo.
+ when(campaignRepository.save(any(Campaign.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
// Act
Campaign result = campaignService.createCampaign(data);
@@ -73,7 +76,8 @@ public class CampaignServiceTest {
null,
null
);
- when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign);
+ when(campaignRepository.save(any(Campaign.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
// Act
Campaign result = campaignService.createCampaign(data);
@@ -93,7 +97,8 @@ public class CampaignServiceTest {
" ",
null
);
- when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign);
+ when(campaignRepository.save(any(Campaign.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
// Act
Campaign result = campaignService.createCampaign(data);
diff --git a/core/src/test/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilderTest.java b/core/src/test/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilderTest.java
index ade4664..dd89b4d 100644
--- a/core/src/test/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilderTest.java
+++ b/core/src/test/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilderTest.java
@@ -8,6 +8,7 @@ import com.loremind.domain.campaigncontext.SceneBranch;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
+import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import org.junit.jupiter.api.BeforeEach;
@@ -40,6 +41,8 @@ public class CampaignStructuralContextBuilderTest {
private ChapterRepository chapterRepository;
@Mock
private SceneRepository sceneRepository;
+ @Mock
+ private CharacterRepository characterRepository;
@InjectMocks
private CampaignStructuralContextBuilder builder;
diff --git a/web/src/app/campaigns/campaign-create/campaign-create.component.scss b/web/src/app/campaigns/campaign-create/campaign-create.component.scss
index eba7d98..3086fe2 100644
--- a/web/src/app/campaigns/campaign-create/campaign-create.component.scss
+++ b/web/src/app/campaigns/campaign-create/campaign-create.component.scss
@@ -15,6 +15,24 @@
padding: 2rem;
width: 100%;
max-width: 600px;
+ // Le contenu peut dépasser la hauteur de l'écran (formulaire long) :
+ // on borne la modale et on fait scroller l'intérieur en flex-column.
+ max-height: 90vh;
+ display: flex;
+ flex-direction: column;
+}
+
+.modal-header { flex-shrink: 0; }
+
+form {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ flex: 1;
+ overflow-y: auto;
+ // Marge interne pour que la scrollbar ne colle pas aux inputs.
+ margin-right: -0.5rem;
+ padding-right: 0.5rem;
}
.modal-header {
@@ -87,6 +105,14 @@
.modal-actions {
display: flex;
gap: 1rem;
+ // Actions collées en bas du scroll : visibles même si on n'a pas défilé
+ // jusqu'en bas du formulaire.
+ position: sticky;
+ bottom: 0;
+ background: #111827;
+ padding-top: 1rem;
+ margin-top: auto;
+ flex-shrink: 0;
}
.btn-primary {
diff --git a/web/src/app/lore/lore-node-create/lore-node-create.component.html b/web/src/app/lore/lore-node-create/lore-node-create.component.html
index c043d12..6fcc85e 100644
--- a/web/src/app/lore/lore-node-create/lore-node-create.component.html
+++ b/web/src/app/lore/lore-node-create/lore-node-create.component.html
@@ -49,15 +49,6 @@
-
- Adresse
-
-
-
@@ -43,11 +43,21 @@
Dossier de destination *
-
- Sélectionnez un dossier
- {{ node.name }}
-
-
La page sera créée dans ce dossier
+
+
+
+ Sélectionnez un dossier
+ {{ node.name }}
+
+ La page sera créée dans ce dossier
+
+
+
+
+ Aucun dossier dans ce Lore.
+ Créer un dossier d'abord.
+
+
diff --git a/web/src/app/lore/page-create/page-create.component.ts b/web/src/app/lore/page-create/page-create.component.ts
index 079ea31..47c20b5 100644
--- a/web/src/app/lore/page-create/page-create.component.ts
+++ b/web/src/app/lore/page-create/page-create.component.ts
@@ -92,9 +92,48 @@ export class PageCreateComponent implements OnInit, OnDestroy {
if (this.preselectedNodeId) {
this.form.patchValue({ nodeId: this.preselectedNodeId });
}
+
+ this.restoreDraft();
});
}
+ /** Clé sessionStorage pour le brouillon — scopée au lore courant. */
+ private get draftKey(): string {
+ return `page-create-draft:${this.loreId}`;
+ }
+
+ /**
+ * Sauvegarde le titre et le template sélectionné avant un détour de navigation
+ * (création de template ou de dossier), pour pouvoir les restaurer au retour.
+ * NodeId volontairement omis : il peut référencer un dossier qui n'existait
+ * pas encore et serait invalide après un aller-retour.
+ */
+ saveDraft(): void {
+ const draft = {
+ title: this.form.value.title ?? '',
+ selectedTemplateId: this.selectedTemplateId
+ };
+ if (!draft.title && !draft.selectedTemplateId) return;
+ try {
+ sessionStorage.setItem(this.draftKey, JSON.stringify(draft));
+ } catch { /* quota dépassé ou storage indisponible : on ignore */ }
+ }
+
+ private restoreDraft(): void {
+ let raw: string | null = null;
+ try { raw = sessionStorage.getItem(this.draftKey); } catch { return; }
+ if (!raw) return;
+ sessionStorage.removeItem(this.draftKey);
+ try {
+ const draft = JSON.parse(raw) as { title?: string; selectedTemplateId?: string | null };
+ if (draft.title) this.form.patchValue({ title: draft.title });
+ if (draft.selectedTemplateId && this.templates.some(t => t.id === draft.selectedTemplateId)) {
+ const tpl = this.templates.find(t => t.id === draft.selectedTemplateId)!;
+ this.selectTemplate(tpl);
+ }
+ } catch { /* JSON corrompu : on ignore */ }
+ }
+
selectTemplate(template: Template): void {
this.selectedTemplateId = template.id!;
// Si pas de noeud pré-choisi par l'URL, on pré-remplit avec le defaultNodeId du template.
diff --git a/web/src/app/lore/return-stack.helper.ts b/web/src/app/lore/return-stack.helper.ts
new file mode 100644
index 0000000..87c1602
--- /dev/null
+++ b/web/src/app/lore/return-stack.helper.ts
@@ -0,0 +1,22 @@
+/**
+ * Gère la pile de retours partagée par les écrans de création imbriqués
+ * (page-create ↔ template-create ↔ node-create).
+ *
+ * La pile est encodée dans le query-param `returnTo` sous forme de chaîne
+ * séparée par des virgules, ex : `"template-create,page-create"`. Chaque
+ * écran dépile le premier élément pour savoir où revenir, et propage le
+ * reste comme nouveau `returnTo`.
+ */
+export interface PoppedReturn {
+ /** Nom de l'écran vers lequel revenir, ou null si la pile est vide. */
+ next: string | null;
+ /** Reste de la pile à transmettre à l'écran de retour, ou null si vide. */
+ rest: string | null;
+}
+
+export function popReturnTo(raw: string | null | undefined): PoppedReturn {
+ const parts = (raw ?? '').split(',').map(s => s.trim()).filter(Boolean);
+ const next = parts.shift() ?? null;
+ const rest = parts.length ? parts.join(',') : null;
+ return { next, rest };
+}
diff --git a/web/src/app/lore/template-create/template-create.component.html b/web/src/app/lore/template-create/template-create.component.html
index a8d62c8..8a671f3 100644
--- a/web/src/app/lore/template-create/template-create.component.html
+++ b/web/src/app/lore/template-create/template-create.component.html
@@ -22,11 +22,23 @@
Dossier par défaut *
-
- Sélectionnez un dossier
- {{ node.name }}
-
-
Les pages créées avec ce template seront placées dans ce dossier
+
+
+
+ Sélectionnez un dossier
+ {{ node.name }}
+
+ Les pages créées avec ce template seront placées dans ce dossier
+
+
+
+
+ Aucun dossier dans ce Lore.
+ Créer un dossier d'abord.
+
+
@@ -85,7 +97,7 @@
type="text"
[(ngModel)]="newFieldName"
[ngModelOptions]="{ standalone: true }"
- placeholder="Nom du champ..."
+ placeholder="+ Ajouter un champ"
(keydown.enter)="$event.preventDefault(); addField()" />
{
this.nodes = data.nodes;
this.layoutService.show(buildLoreSidebarConfig(data));
+ this.restoreDraft();
});
}
+ /** Clé sessionStorage pour le brouillon de template — scopée au lore. */
+ private get draftKey(): string {
+ return `template-create-draft:${this.loreId}`;
+ }
+
+ /**
+ * Sauvegarde le formulaire courant avant un détour (création de dossier).
+ * defaultNodeId volontairement omis : il référence potentiellement un dossier
+ * qui n'existe pas encore.
+ */
+ saveDraft(): void {
+ const draft = {
+ name: this.form.value.name ?? '',
+ description: this.form.value.description ?? '',
+ fields: this.fields
+ };
+ try {
+ sessionStorage.setItem(this.draftKey, JSON.stringify(draft));
+ } catch { /* storage indisponible : on ignore */ }
+ }
+
+ private restoreDraft(): void {
+ let raw: string | null = null;
+ try { raw = sessionStorage.getItem(this.draftKey); } catch { return; }
+ if (!raw) return;
+ sessionStorage.removeItem(this.draftKey);
+ try {
+ const draft = JSON.parse(raw) as { name?: string; description?: string; fields?: TemplateField[] };
+ if (draft.name) this.form.patchValue({ name: draft.name });
+ if (draft.description) this.form.patchValue({ description: draft.description });
+ if (Array.isArray(draft.fields) && draft.fields.length) this.fields = draft.fields;
+ } catch { /* JSON corrompu : on ignore */ }
+ }
+
+ /**
+ * Construit le `returnTo` à passer à l'écran de création de dossier :
+ * on empile 'template-create' par-dessus la pile courante, pour que node-create
+ * revienne ici puis remonte à l'écran d'origine le cas échéant.
+ */
+ get nodeCreateReturnTo(): string {
+ const current = this.route.snapshot.queryParamMap.get('returnTo');
+ return current ? `template-create,${current}` : 'template-create';
+ }
+
addField(): void {
const name = this.newFieldName.trim();
if (!name) return;
@@ -129,12 +175,28 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
defaultNodeId: raw.defaultNodeId,
fields: this.fields
}).subscribe({
- next: () => this.router.navigate(['/lore', this.loreId]),
+ next: () => this.navigateBack(),
error: () => console.error('Erreur lors de la création du template')
});
}
cancel(): void {
+ this.navigateBack();
+ }
+
+ /**
+ * Redirige vers l'écran d'origine en dépilant le premier élément du query-param
+ * `returnTo` (pile de retours séparés par des virgules, ex : `page-create` ou
+ * `template-create,page-create`). Sinon retombe sur la page détail du Lore.
+ */
+ private navigateBack(): void {
+ const { next, rest } = popReturnTo(this.route.snapshot.queryParamMap.get('returnTo'));
+ if (next === 'page-create') {
+ this.router.navigate(['/lore', this.loreId, 'pages', 'create'], {
+ queryParams: rest ? { returnTo: rest } : {}
+ });
+ return;
+ }
this.router.navigate(['/lore', this.loreId]);
}
diff --git a/web/src/app/lore/template-edit/template-edit.component.html b/web/src/app/lore/template-edit/template-edit.component.html
index 2657206..265afb1 100644
--- a/web/src/app/lore/template-edit/template-edit.component.html
+++ b/web/src/app/lore/template-edit/template-edit.component.html
@@ -58,7 +58,10 @@
-
+
{{ f.name }}
@@ -79,8 +82,8 @@
Mosaique
Carrousel
-
-
+
+
diff --git a/web/src/app/lore/template-edit/template-edit.component.scss b/web/src/app/lore/template-edit/template-edit.component.scss
index db6c1ad..fdddbfc 100644
--- a/web/src/app/lore/template-edit/template-edit.component.scss
+++ b/web/src/app/lore/template-edit/template-edit.component.scss
@@ -107,9 +107,23 @@
align-items: center;
gap: 0.45rem;
- // Discriminant visuel pour les champs IMAGE (palette indigo).
+ // Champ existant, chargé depuis le backend — orange ambre.
+ &.field-chip-existing {
+ background: #5a3a1a;
+ border-color: #7a4f22;
+ color: #fde4c0;
+ }
+
+ // Champ ajouté pendant cette session, pas encore sauvegardé — vert.
+ &.field-chip-new {
+ background: #2a5f3f;
+ border-color: #347a4f;
+ color: #d1fae5;
+ }
+
+ // Champ IMAGE (palette indigo) — prioritaire sur existing/new.
&.field-chip-image {
- background: #1f1b3a;
+ background: #312b5c;
border-color: #3d3566;
color: #c7b8ff;
}
@@ -118,11 +132,33 @@
.btn-type-toggle {
width: auto;
padding: 0 0.7rem;
+ background: #2a2a3d;
+ color: #d1d5db;
font-size: 0.72rem;
letter-spacing: 0.02em;
- color: #9ca3af;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: background 0.15s, color 0.15s;
+ height: 32px;
- &:hover { color: #a5b4fc; background: #1f1b3a; }
+ &:hover { background: #363650; color: white; }
+ }
+
+ .btn-icon-danger {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ background: #3f1f1f;
+ color: #fca5a5;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: background 0.15s;
+
+ &:hover { background: #5a2a2a; }
}
.type-select,
@@ -227,15 +263,14 @@
justify-content: center;
width: 36px;
height: 36px;
- margin-right: 0.4rem;
- background: transparent;
- color: #6c63ff;
+ background: #6c63ff;
+ color: white;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
- &:hover { background: #2a2a3d; }
+ &:hover { background: #5a52d6; }
}
.btn-primary, .btn-secondary, .btn-danger {
diff --git a/web/src/app/lore/template-edit/template-edit.component.ts b/web/src/app/lore/template-edit/template-edit.component.ts
index 8f6abf8..50d597c 100644
--- a/web/src/app/lore/template-edit/template-edit.component.ts
+++ b/web/src/app/lore/template-edit/template-edit.component.ts
@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs';
-import { LucideAngularModule, Plus, X, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown } from 'lucide-angular';
+import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown } from 'lucide-angular';
import { LoreService } from '../../services/lore.service';
import { TemplateService } from '../../services/template.service';
import { PageService } from '../../services/page.service';
@@ -26,7 +26,6 @@ import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.hel
})
export class TemplateEditComponent implements OnInit, OnDestroy {
readonly Plus = Plus;
- readonly X = X;
readonly Trash2 = Trash2;
readonly Type = Type;
readonly ImageIcon = ImageIcon;
@@ -41,6 +40,17 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
fields: TemplateField[] = [];
newFieldName = '';
newFieldType: FieldType = 'TEXT';
+ /**
+ * Noms des champs chargés depuis le backend — utilisés pour discriminer
+ * visuellement les champs existants (orange) des champs ajoutés dans cette
+ * session d'édition (vert). Non muté ensuite.
+ */
+ private originalFieldNames = new Set();
+
+ /** True si le champ est présent depuis le chargement du template. */
+ isExistingField(field: TemplateField): boolean {
+ return this.originalFieldNames.has(field.name);
+ }
constructor(
private fb: FormBuilder,
@@ -83,6 +93,7 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
? { name: f.name, type, layout: f.layout ?? 'GALLERY' }
: { name: f.name, type };
});
+ this.originalFieldNames = new Set(this.fields.map(f => f.name));
this.form.patchValue({
name: template.name,
description: template.description,
diff --git a/web/src/app/services/layout.service.ts b/web/src/app/services/layout.service.ts
index 2911ab3..43f7ac0 100644
--- a/web/src/app/services/layout.service.ts
+++ b/web/src/app/services/layout.service.ts
@@ -62,6 +62,8 @@ export interface BottomPanel {
title: string;
items: BottomPanelItem[];
initiallyOpen?: boolean;
+ /** Action "+" inline dans le header — créer un item sans déplier le panneau. */
+ headerAction?: { label: string; route: string };
}
export interface SecondarySidebarConfig {
diff --git a/web/src/app/services/lore.model.ts b/web/src/app/services/lore.model.ts
index 61568aa..bae6704 100644
--- a/web/src/app/services/lore.model.ts
+++ b/web/src/app/services/lore.model.ts
@@ -24,14 +24,12 @@ export interface LoreNode {
/** Champs historiques non encore persistés côté backend — gardés pour compat de l'UI. */
type?: string;
description?: string;
- address?: string;
}
export interface LoreNodeCreate {
name: string;
icon: string;
description: string;
- address: string;
parentId?: string | null;
loreId: string;
}
diff --git a/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.html b/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.html
index e2a0523..bc0626f 100644
--- a/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.html
+++ b/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.html
@@ -1,4 +1,6 @@
-