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 @@ -
- - -
-
@@ -43,11 +43,21 @@
- -

La page sera créée dans ce dossier

+ + + +

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 @@
- -

Les pages créées avec ce template seront placées dans ce dossier

+ + + +

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()" /> - 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 @@ -