Corrections d'ordre graphique / ergonomique :
- Lorsqu'on part de zéro : la création de dossier / page / template ce fait de manière plus fluide à la création d'un lore (par exemple création de page sans template et dossier : parcours facilité) - Ajout d'un bouton "+" dans le header templates - Harmonisation création / modification template Correction de tests unitaires
This commit is contained in:
@@ -53,7 +53,10 @@ public class CampaignServiceTest {
|
|||||||
"lore-123",
|
"lore-123",
|
||||||
null
|
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
|
// Act
|
||||||
Campaign result = campaignService.createCampaign(data);
|
Campaign result = campaignService.createCampaign(data);
|
||||||
@@ -73,7 +76,8 @@ public class CampaignServiceTest {
|
|||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign);
|
when(campaignRepository.save(any(Campaign.class)))
|
||||||
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
Campaign result = campaignService.createCampaign(data);
|
Campaign result = campaignService.createCampaign(data);
|
||||||
@@ -93,7 +97,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
|
// Act
|
||||||
Campaign result = campaignService.createCampaign(data);
|
Campaign result = campaignService.createCampaign(data);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.loremind.domain.campaigncontext.SceneBranch;
|
|||||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
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.campaigncontext.ports.SceneRepository;
|
||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
@@ -40,6 +41,8 @@ public class CampaignStructuralContextBuilderTest {
|
|||||||
private ChapterRepository chapterRepository;
|
private ChapterRepository chapterRepository;
|
||||||
@Mock
|
@Mock
|
||||||
private SceneRepository sceneRepository;
|
private SceneRepository sceneRepository;
|
||||||
|
@Mock
|
||||||
|
private CharacterRepository characterRepository;
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private CampaignStructuralContextBuilder builder;
|
private CampaignStructuralContextBuilder builder;
|
||||||
|
|||||||
@@ -15,6 +15,24 @@
|
|||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 600px;
|
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 {
|
.modal-header {
|
||||||
@@ -87,6 +105,14 @@
|
|||||||
.modal-actions {
|
.modal-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
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 {
|
.btn-primary {
|
||||||
|
|||||||
@@ -49,15 +49,6 @@
|
|||||||
</textarea>
|
</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label>Adresse</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
formControlName="address"
|
|
||||||
placeholder="nom-du-dossier"
|
|
||||||
/>
|
|
||||||
</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">
|
||||||
<lucide-icon [img]="getIcon(selectedIcon)" [size]="16"></lucide-icon>
|
<lucide-icon [img]="getIcon(selectedIcon)" [size]="16"></lucide-icon>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { PageService } from '../../services/page.service';
|
|||||||
import { LayoutService } from '../../services/layout.service';
|
import { LayoutService } from '../../services/layout.service';
|
||||||
import { LoreNode } from '../../services/lore.model';
|
import { LoreNode } from '../../services/lore.model';
|
||||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||||
|
import { popReturnTo } from '../return-stack.helper';
|
||||||
import { LORE_ICON_OPTIONS, IconOption, resolveIcon } from '../lore-icons';
|
import { LORE_ICON_OPTIONS, IconOption, resolveIcon } from '../lore-icons';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -42,15 +43,8 @@ export class LoreNodeCreateComponent implements OnInit, OnDestroy {
|
|||||||
this.form = this.fb.group({
|
this.form = this.fb.group({
|
||||||
name: ['', Validators.required],
|
name: ['', Validators.required],
|
||||||
description: [''],
|
description: [''],
|
||||||
address: ['', Validators.required],
|
|
||||||
parentId: [''] // '' = racine
|
parentId: [''] // '' = racine
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-génère l'adresse depuis le nom
|
|
||||||
this.form.get('name')!.valueChanges.subscribe(name => {
|
|
||||||
const slug = (name as string).toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
|
||||||
this.form.get('address')!.setValue(slug, { emitEvent: false });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -84,17 +78,35 @@ export class LoreNodeCreateComponent implements OnInit, OnDestroy {
|
|||||||
this.loreService.createLoreNode({
|
this.loreService.createLoreNode({
|
||||||
name: raw.name,
|
name: raw.name,
|
||||||
description: raw.description,
|
description: raw.description,
|
||||||
address: raw.address,
|
|
||||||
icon: this.selectedIcon,
|
icon: this.selectedIcon,
|
||||||
parentId: raw.parentId && raw.parentId !== '' ? raw.parentId : null,
|
parentId: raw.parentId && raw.parentId !== '' ? raw.parentId : null,
|
||||||
loreId: this.loreId
|
loreId: this.loreId
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: () => this.router.navigate(['/lore', this.loreId]),
|
next: () => this.navigateBack(),
|
||||||
error: () => console.error('Erreur lors de la création du dossier')
|
error: () => console.error('Erreur lors de la création du dossier')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel(): void {
|
cancel(): void {
|
||||||
|
this.navigateBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirige vers l'écran d'origine en dépilant le premier élément du query-param
|
||||||
|
* `returnTo` (pile séparée par des virgules). Supporte `page-create` et
|
||||||
|
* `template-create`, en transmettant le reste de la pile à l'écran suivant.
|
||||||
|
*/
|
||||||
|
private navigateBack(): void {
|
||||||
|
const { next, rest } = popReturnTo(this.route.snapshot.queryParamMap.get('returnTo'));
|
||||||
|
const qp = rest ? { returnTo: rest } : {};
|
||||||
|
if (next === 'page-create') {
|
||||||
|
this.router.navigate(['/lore', this.loreId, 'pages', 'create'], { queryParams: qp });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (next === 'template-create') {
|
||||||
|
this.router.navigate(['/lore', this.loreId, 'templates', 'create'], { queryParams: qp });
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.router.navigate(['/lore', this.loreId]);
|
this.router.navigate(['/lore', this.loreId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,8 +63,9 @@ export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarC
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construit récursivement le TreeItem d'un dossier :
|
* Construit récursivement le TreeItem d'un dossier : ses sous-dossiers,
|
||||||
* ses sous-dossiers, puis ses pages, puis les actions "+ Nouveau dossier" et "+ Nouvelle page".
|
* ses pages, et deux actions révélées au survol de la ligne (pas dans la
|
||||||
|
* hiérarchie) — "Nouveau sous-dossier" et "Nouvelle page".
|
||||||
*/
|
*/
|
||||||
const buildFolderItem = (node: LoreNode): TreeItem => {
|
const buildFolderItem = (node: LoreNode): TreeItem => {
|
||||||
const subFolders = childrenByParent.get(node.id!) ?? [];
|
const subFolders = childrenByParent.get(node.id!) ?? [];
|
||||||
@@ -116,20 +117,16 @@ export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarC
|
|||||||
id: 'templates',
|
id: 'templates',
|
||||||
title: 'Templates',
|
title: 'Templates',
|
||||||
initiallyOpen: true,
|
initiallyOpen: true,
|
||||||
items: [
|
headerAction: {
|
||||||
...templates.map(t => ({
|
label: 'Nouveau template',
|
||||||
id: t.id!,
|
route: `/lore/${lore.id}/templates/create`
|
||||||
label: t.name,
|
},
|
||||||
meta: `${t.fieldCount ?? t.fields.length} champs`,
|
items: templates.map(t => ({
|
||||||
route: `/lore/${lore.id}/templates/${t.id}`
|
id: t.id!,
|
||||||
})),
|
label: t.name,
|
||||||
{
|
meta: `${t.fieldCount ?? t.fields.length} champs`,
|
||||||
id: 'create-template',
|
route: `/lore/${lore.id}/templates/${t.id}`
|
||||||
label: '+ Nouveau template',
|
}))
|
||||||
isAction: true,
|
|
||||||
route: `/lore/${lore.id}/templates/create`
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
<ng-template #emptyTemplates>
|
<ng-template #emptyTemplates>
|
||||||
<p class="empty-hint">
|
<p class="empty-hint">
|
||||||
Aucun template défini pour ce Lore.
|
Aucun template défini pour ce Lore.
|
||||||
<a [routerLink]="['/lore', loreId, 'templates', 'create']">Créer un template</a> d'abord.
|
<a [routerLink]="['/lore', loreId, 'templates', 'create']" [queryParams]="{ returnTo: 'page-create' }" (click)="saveDraft()">Créer un template</a> d'abord.
|
||||||
</p>
|
</p>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,11 +43,21 @@
|
|||||||
<!-- Dossier de destination -->
|
<!-- Dossier de destination -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Dossier de destination *</label>
|
<label>Dossier de destination *</label>
|
||||||
<select formControlName="nodeId" [attr.disabled]="preselectedNodeId ? true : null">
|
|
||||||
<option value="" disabled>Sélectionnez un dossier</option>
|
<ng-container *ngIf="nodes.length; else emptyFolders">
|
||||||
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option>
|
<select formControlName="nodeId" [attr.disabled]="preselectedNodeId ? true : null">
|
||||||
</select>
|
<option value="" disabled>Sélectionnez un dossier</option>
|
||||||
<p class="hint">La page sera créée dans ce dossier</p>
|
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option>
|
||||||
|
</select>
|
||||||
|
<p class="hint">La page sera créée dans ce dossier</p>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-template #emptyFolders>
|
||||||
|
<p class="empty-hint">
|
||||||
|
Aucun dossier dans ce Lore.
|
||||||
|
<a [routerLink]="['/lore', loreId, 'nodes', 'create']" [queryParams]="{ returnTo: 'page-create' }" (click)="saveDraft()">Créer un dossier</a> d'abord.
|
||||||
|
</p>
|
||||||
|
</ng-template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Aide contextuelle -->
|
<!-- Aide contextuelle -->
|
||||||
|
|||||||
@@ -92,9 +92,48 @@ export class PageCreateComponent implements OnInit, OnDestroy {
|
|||||||
if (this.preselectedNodeId) {
|
if (this.preselectedNodeId) {
|
||||||
this.form.patchValue({ nodeId: 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 {
|
selectTemplate(template: Template): void {
|
||||||
this.selectedTemplateId = template.id!;
|
this.selectedTemplateId = template.id!;
|
||||||
// Si pas de noeud pré-choisi par l'URL, on pré-remplit avec le defaultNodeId du template.
|
// Si pas de noeud pré-choisi par l'URL, on pré-remplit avec le defaultNodeId du template.
|
||||||
|
|||||||
22
web/src/app/lore/return-stack.helper.ts
Normal file
22
web/src/app/lore/return-stack.helper.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
@@ -22,11 +22,23 @@
|
|||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Dossier par défaut *</label>
|
<label>Dossier par défaut *</label>
|
||||||
<select formControlName="defaultNodeId">
|
|
||||||
<option value="" disabled>Sélectionnez un dossier</option>
|
<ng-container *ngIf="nodes.length; else emptyFolders">
|
||||||
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option>
|
<select formControlName="defaultNodeId">
|
||||||
</select>
|
<option value="" disabled>Sélectionnez un dossier</option>
|
||||||
<p class="hint">Les pages créées avec ce template seront placées dans ce dossier</p>
|
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option>
|
||||||
|
</select>
|
||||||
|
<p class="hint">Les pages créées avec ce template seront placées dans ce dossier</p>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-template #emptyFolders>
|
||||||
|
<p class="empty-hint">
|
||||||
|
Aucun dossier dans ce Lore.
|
||||||
|
<a [routerLink]="['/lore', loreId, 'nodes', 'create']"
|
||||||
|
[queryParams]="{ returnTo: nodeCreateReturnTo }"
|
||||||
|
(click)="saveDraft()">Créer un dossier</a> d'abord.
|
||||||
|
</p>
|
||||||
|
</ng-template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -85,7 +97,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
[(ngModel)]="newFieldName"
|
[(ngModel)]="newFieldName"
|
||||||
[ngModelOptions]="{ standalone: true }"
|
[ngModelOptions]="{ standalone: true }"
|
||||||
placeholder="Nom du champ..."
|
placeholder="+ Ajouter un champ"
|
||||||
(keydown.enter)="$event.preventDefault(); addField()" />
|
(keydown.enter)="$event.preventDefault(); addField()" />
|
||||||
<select
|
<select
|
||||||
class="type-select"
|
class="type-select"
|
||||||
|
|||||||
@@ -247,3 +247,10 @@
|
|||||||
|
|
||||||
&:hover { background: #363650; }
|
&:hover { background: #363650; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
|
||||||
|
a { color: #a5b4fc; text-decoration: underline; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
import { LucideAngularModule, Plus, 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 { LoreService } from '../../services/lore.service';
|
||||||
import { TemplateService } from '../../services/template.service';
|
import { TemplateService } from '../../services/template.service';
|
||||||
@@ -10,6 +10,7 @@ import { LayoutService } from '../../services/layout.service';
|
|||||||
import { LoreNode } from '../../services/lore.model';
|
import { LoreNode } from '../../services/lore.model';
|
||||||
import { FieldType, ImageLayout, TemplateField } from '../../services/template.model';
|
import { FieldType, ImageLayout, TemplateField } from '../../services/template.model';
|
||||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||||
|
import { popReturnTo } from '../return-stack.helper';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran de création d'un Template (gabarit de Page).
|
* Écran de création d'un Template (gabarit de Page).
|
||||||
@@ -20,7 +21,7 @@ import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.hel
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-template-create',
|
selector: 'app-template-create',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, ReactiveFormsModule, LucideAngularModule],
|
imports: [CommonModule, FormsModule, ReactiveFormsModule, RouterModule, LucideAngularModule],
|
||||||
templateUrl: './template-create.component.html',
|
templateUrl: './template-create.component.html',
|
||||||
styleUrls: ['./template-create.component.scss']
|
styleUrls: ['./template-create.component.scss']
|
||||||
})
|
})
|
||||||
@@ -69,9 +70,54 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
|
|||||||
loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService).subscribe(data => {
|
loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService).subscribe(data => {
|
||||||
this.nodes = data.nodes;
|
this.nodes = data.nodes;
|
||||||
this.layoutService.show(buildLoreSidebarConfig(data));
|
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 {
|
addField(): void {
|
||||||
const name = this.newFieldName.trim();
|
const name = this.newFieldName.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
@@ -129,12 +175,28 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
|
|||||||
defaultNodeId: raw.defaultNodeId,
|
defaultNodeId: raw.defaultNodeId,
|
||||||
fields: this.fields
|
fields: this.fields
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: () => this.router.navigate(['/lore', this.loreId]),
|
next: () => this.navigateBack(),
|
||||||
error: () => console.error('Erreur lors de la création du template')
|
error: () => console.error('Erreur lors de la création du template')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel(): void {
|
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]);
|
this.router.navigate(['/lore', this.loreId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,10 @@
|
|||||||
<lucide-icon [img]="ChevronDown" [size]="12"></lucide-icon>
|
<lucide-icon [img]="ChevronDown" [size]="12"></lucide-icon>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span class="field-chip" [class.field-chip-image]="f.type === 'IMAGE'">
|
<span class="field-chip"
|
||||||
|
[class.field-chip-image]="f.type === 'IMAGE'"
|
||||||
|
[class.field-chip-existing]="f.type !== 'IMAGE' && isExistingField(f)"
|
||||||
|
[class.field-chip-new]="f.type !== 'IMAGE' && !isExistingField(f)">
|
||||||
<lucide-icon [img]="f.type === 'IMAGE' ? ImageIcon : Type" [size]="12"></lucide-icon>
|
<lucide-icon [img]="f.type === 'IMAGE' ? ImageIcon : Type" [size]="12"></lucide-icon>
|
||||||
{{ f.name }}
|
{{ f.name }}
|
||||||
</span>
|
</span>
|
||||||
@@ -79,8 +82,8 @@
|
|||||||
<option value="MASONRY">Mosaique</option>
|
<option value="MASONRY">Mosaique</option>
|
||||||
<option value="CAROUSEL">Carrousel</option>
|
<option value="CAROUSEL">Carrousel</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="button" class="btn-icon-ghost" (click)="removeField(i)" aria-label="Supprimer">
|
<button type="button" class="btn-icon-danger" (click)="removeField(i)" aria-label="Supprimer">
|
||||||
<lucide-icon [img]="X" [size]="14"></lucide-icon>
|
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -107,9 +107,23 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.45rem;
|
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 {
|
&.field-chip-image {
|
||||||
background: #1f1b3a;
|
background: #312b5c;
|
||||||
border-color: #3d3566;
|
border-color: #3d3566;
|
||||||
color: #c7b8ff;
|
color: #c7b8ff;
|
||||||
}
|
}
|
||||||
@@ -118,11 +132,33 @@
|
|||||||
.btn-type-toggle {
|
.btn-type-toggle {
|
||||||
width: auto;
|
width: auto;
|
||||||
padding: 0 0.7rem;
|
padding: 0 0.7rem;
|
||||||
|
background: #2a2a3d;
|
||||||
|
color: #d1d5db;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
letter-spacing: 0.02em;
|
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,
|
.type-select,
|
||||||
@@ -227,15 +263,14 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
margin-right: 0.4rem;
|
background: #6c63ff;
|
||||||
background: transparent;
|
color: white;
|
||||||
color: #6c63ff;
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
|
|
||||||
&:hover { background: #2a2a3d; }
|
&:hover { background: #5a52d6; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary, .btn-secondary, .btn-danger {
|
.btn-primary, .btn-secondary, .btn-danger {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { forkJoin } from 'rxjs';
|
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 { LoreService } from '../../services/lore.service';
|
||||||
import { TemplateService } from '../../services/template.service';
|
import { TemplateService } from '../../services/template.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { PageService } from '../../services/page.service';
|
||||||
@@ -26,7 +26,6 @@ import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.hel
|
|||||||
})
|
})
|
||||||
export class TemplateEditComponent implements OnInit, OnDestroy {
|
export class TemplateEditComponent implements OnInit, OnDestroy {
|
||||||
readonly Plus = Plus;
|
readonly Plus = Plus;
|
||||||
readonly X = X;
|
|
||||||
readonly Trash2 = Trash2;
|
readonly Trash2 = Trash2;
|
||||||
readonly Type = Type;
|
readonly Type = Type;
|
||||||
readonly ImageIcon = ImageIcon;
|
readonly ImageIcon = ImageIcon;
|
||||||
@@ -41,6 +40,17 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
|||||||
fields: TemplateField[] = [];
|
fields: TemplateField[] = [];
|
||||||
newFieldName = '';
|
newFieldName = '';
|
||||||
newFieldType: FieldType = 'TEXT';
|
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<string>();
|
||||||
|
|
||||||
|
/** True si le champ est présent depuis le chargement du template. */
|
||||||
|
isExistingField(field: TemplateField): boolean {
|
||||||
|
return this.originalFieldNames.has(field.name);
|
||||||
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private fb: FormBuilder,
|
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, layout: f.layout ?? 'GALLERY' }
|
||||||
: { name: f.name, type };
|
: { name: f.name, type };
|
||||||
});
|
});
|
||||||
|
this.originalFieldNames = new Set(this.fields.map(f => f.name));
|
||||||
this.form.patchValue({
|
this.form.patchValue({
|
||||||
name: template.name,
|
name: template.name,
|
||||||
description: template.description,
|
description: template.description,
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export interface BottomPanel {
|
|||||||
title: string;
|
title: string;
|
||||||
items: BottomPanelItem[];
|
items: BottomPanelItem[];
|
||||||
initiallyOpen?: boolean;
|
initiallyOpen?: boolean;
|
||||||
|
/** Action "+" inline dans le header — créer un item sans déplier le panneau. */
|
||||||
|
headerAction?: { label: string; route: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SecondarySidebarConfig {
|
export interface SecondarySidebarConfig {
|
||||||
|
|||||||
@@ -24,14 +24,12 @@ export interface LoreNode {
|
|||||||
/** Champs historiques non encore persistés côté backend — gardés pour compat de l'UI. */
|
/** Champs historiques non encore persistés côté backend — gardés pour compat de l'UI. */
|
||||||
type?: string;
|
type?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
address?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoreNodeCreate {
|
export interface LoreNodeCreate {
|
||||||
name: string;
|
name: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
description: string;
|
description: string;
|
||||||
address: string;
|
|
||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
loreId: string;
|
loreId: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<aside class="secondary-sidebar" [class.collapsed]="isCollapsed">
|
<aside class="secondary-sidebar"
|
||||||
|
[class.collapsed]="isCollapsed"
|
||||||
|
[style.width.px]="isCollapsed ? null : width">
|
||||||
|
|
||||||
<div class="collapse-toggle" (click)="toggleCollapse()">
|
<div class="collapse-toggle" (click)="toggleCollapse()">
|
||||||
<lucide-icon [img]="isCollapsed ? PanelLeftOpen : PanelLeftClose" [size]="16"></lucide-icon>
|
<lucide-icon [img]="isCollapsed ? PanelLeftOpen : PanelLeftClose" [size]="16"></lucide-icon>
|
||||||
@@ -61,42 +63,38 @@
|
|||||||
[title]="a.label"
|
[title]="a.label"
|
||||||
[attr.aria-label]="a.label"
|
[attr.aria-label]="a.label"
|
||||||
(click)="runCreateAction($event, a)">
|
(click)="runCreateAction($event, a)">
|
||||||
<lucide-icon [img]="iconForAction(a)" [size]="12"></lucide-icon>
|
<lucide-icon [img]="iconForAction(a)" [size]="16"></lucide-icon>
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tree-children" *ngIf="isExpanded(item.id) && (hasChildren(item) || item.createActions?.length)">
|
<div class="tree-children" *ngIf="isExpanded(item.id) && hasChildren(item)">
|
||||||
<ng-container *ngFor="let child of item.children">
|
<ng-container *ngFor="let child of item.children">
|
||||||
<ng-container *ngTemplateOutlet="treeNode; context: { $implicit: child, level: level + 1 }"></ng-container>
|
<ng-container *ngTemplateOutlet="treeNode; context: { $implicit: child, level: level + 1 }"></ng-container>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<!-- Empty-state inline : createActions affichees en pleine largeur
|
|
||||||
UNIQUEMENT si le noeud n'a aucun vrai enfant (sinon le hover-reveal
|
|
||||||
sur le parent suffit, pas de pollution visuelle). -->
|
|
||||||
<ng-container *ngIf="!hasChildren(item) && item.createActions?.length">
|
|
||||||
<div class="tree-item empty-action" *ngFor="let a of item.createActions"
|
|
||||||
[style.padding-left.px]="(level + 1) * 12">
|
|
||||||
<div class="tree-row">
|
|
||||||
<span class="chevron-spacer"></span>
|
|
||||||
<button type="button" class="tree-btn action" (click)="runCreateAction($event, a)">
|
|
||||||
<lucide-icon [img]="iconForAction(a)" [size]="12" class="item-icon"></lucide-icon>
|
|
||||||
+ {{ a.label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<!-- Panneau bas (ex: Templates) ------------------------------------ -->
|
<!-- Panneau bas (ex: Templates) ------------------------------------ -->
|
||||||
<section class="bottom-panel" *ngIf="bottomPanel">
|
<section class="bottom-panel" *ngIf="bottomPanel">
|
||||||
<button class="panel-header" (click)="togglePanel()">
|
<div class="panel-header-row">
|
||||||
<span class="panel-title">{{ bottomPanel.title }}</span>
|
<button class="panel-header" (click)="togglePanel()">
|
||||||
<lucide-icon
|
<span class="panel-title">{{ bottomPanel.title }}</span>
|
||||||
[img]="panelOpen ? ChevronDown : ChevronRight"
|
<lucide-icon
|
||||||
[size]="14">
|
[img]="panelOpen ? ChevronDown : ChevronRight"
|
||||||
</lucide-icon>
|
[size]="14">
|
||||||
</button>
|
</lucide-icon>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
*ngIf="bottomPanel.headerAction as action"
|
||||||
|
type="button"
|
||||||
|
class="panel-header-action"
|
||||||
|
[title]="action.label"
|
||||||
|
[attr.aria-label]="action.label"
|
||||||
|
(click)="runPanelHeaderAction($event, action)">
|
||||||
|
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<ul class="panel-list" *ngIf="panelOpen">
|
<ul class="panel-list" *ngIf="panelOpen">
|
||||||
<li *ngFor="let item of bottomPanel.items">
|
<li *ngFor="let item of bottomPanel.items">
|
||||||
<button
|
<button
|
||||||
@@ -112,4 +110,9 @@
|
|||||||
|
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Poignée de redimensionnement sur le bord droit (masquée si replié) -->
|
||||||
|
<div class="resize-handle"
|
||||||
|
*ngIf="!isCollapsed"
|
||||||
|
(mousedown)="startResize($event)"
|
||||||
|
title="Glissez pour redimensionner"></div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -8,15 +8,33 @@
|
|||||||
padding: 1.25rem 0.75rem;
|
padding: 1.25rem 0.75rem;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
transition: width 0.25s ease;
|
position: relative;
|
||||||
|
// Pas de transition sur la largeur : sinon le drag de resize "traîne" derrière la souris.
|
||||||
|
// L'animation d'expand/collapse est gérée uniquement par la classe .collapsed ci-dessous.
|
||||||
|
|
||||||
&.collapsed {
|
&.collapsed {
|
||||||
width: 44px;
|
width: 44px !important;
|
||||||
padding: 1.25rem 0.5rem;
|
padding: 1.25rem 0.5rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
transition: width 0.25s ease;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: -3px;
|
||||||
|
width: 6px;
|
||||||
|
height: 100%;
|
||||||
|
cursor: col-resize;
|
||||||
|
z-index: 10;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:active { background: #6c63ff; }
|
||||||
|
}
|
||||||
|
|
||||||
.collapse-toggle {
|
.collapse-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -142,7 +160,7 @@
|
|||||||
.node-actions {
|
.node-actions {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.1rem;
|
gap: 0.2rem;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -160,17 +178,17 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 22px;
|
width: 28px;
|
||||||
height: 22px;
|
height: 28px;
|
||||||
background: transparent;
|
background: rgba(55, 65, 81, 0.6);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #9ca3af;
|
color: #d1d5db;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
transition: background 0.12s, color 0.12s;
|
transition: background 0.12s, color 0.12s;
|
||||||
|
|
||||||
&:hover { background: #2a2a3d; color: #c7d2fe; }
|
&:hover { background: #4338ca; color: #ffffff; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.chevron-btn {
|
.chevron-btn {
|
||||||
@@ -214,11 +232,36 @@
|
|||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header-action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
background: rgba(108, 99, 255, 0.15);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #c7d2fe;
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
|
||||||
|
&:hover { background: #6c63ff; color: #ffffff; }
|
||||||
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: #a5b4fc;
|
color: #a5b4fc;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, Input, Output, EventEmitter } from '@angular/core';
|
import { Component, Input, Output, EventEmitter, HostListener, OnDestroy, ElementRef } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { LucideAngularModule, ChevronRight, ChevronDown, PanelLeftClose, PanelLeftOpen, Plus, FolderPlus, FilePlus, LucideIconData } from 'lucide-angular';
|
import { LucideAngularModule, ChevronRight, ChevronDown, PanelLeftClose, PanelLeftOpen, Plus, FolderPlus, FilePlus, LucideIconData } from 'lucide-angular';
|
||||||
@@ -12,7 +12,7 @@ import { resolveIcon } from '../../lore/lore-icons';
|
|||||||
templateUrl: './secondary-sidebar.component.html',
|
templateUrl: './secondary-sidebar.component.html',
|
||||||
styleUrls: ['./secondary-sidebar.component.scss']
|
styleUrls: ['./secondary-sidebar.component.scss']
|
||||||
})
|
})
|
||||||
export class SecondarySidebarComponent {
|
export class SecondarySidebarComponent implements OnDestroy {
|
||||||
@Input() title = '';
|
@Input() title = '';
|
||||||
@Input() createActions: SidebarAction[] = [];
|
@Input() createActions: SidebarAction[] = [];
|
||||||
@Input() bottomPanel: BottomPanel | null = null;
|
@Input() bottomPanel: BottomPanel | null = null;
|
||||||
@@ -31,6 +31,17 @@ export class SecondarySidebarComponent {
|
|||||||
|
|
||||||
isCollapsed = false;
|
isCollapsed = false;
|
||||||
|
|
||||||
|
// --- Resize (étirement horizontal) -------------------------------------
|
||||||
|
/** Clé localStorage pour persister la largeur choisie par l'utilisateur. */
|
||||||
|
private static readonly WIDTH_STORAGE_KEY = 'secondary-sidebar-width';
|
||||||
|
private static readonly MIN_WIDTH = 180;
|
||||||
|
private static readonly MAX_WIDTH = 600;
|
||||||
|
private static readonly DEFAULT_WIDTH = 220;
|
||||||
|
|
||||||
|
/** Largeur courante en px (bindée en [style.width.px]). */
|
||||||
|
width = SecondarySidebarComponent.DEFAULT_WIDTH;
|
||||||
|
private isResizing = false;
|
||||||
|
|
||||||
private _items: TreeItem[] = [];
|
private _items: TreeItem[] = [];
|
||||||
|
|
||||||
@Input() set items(value: TreeItem[]) {
|
@Input() set items(value: TreeItem[]) {
|
||||||
@@ -39,7 +50,65 @@ export class SecondarySidebarComponent {
|
|||||||
}
|
}
|
||||||
get items(): TreeItem[] { return this._items; }
|
get items(): TreeItem[] { return this._items; }
|
||||||
|
|
||||||
constructor(private router: Router, private layoutService: LayoutService) {}
|
constructor(
|
||||||
|
private router: Router,
|
||||||
|
private layoutService: LayoutService,
|
||||||
|
private elementRef: ElementRef<HTMLElement>
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(SecondarySidebarComponent.WIDTH_STORAGE_KEY);
|
||||||
|
const parsed = stored ? parseInt(stored, 10) : NaN;
|
||||||
|
if (!isNaN(parsed)) {
|
||||||
|
this.width = Math.min(
|
||||||
|
Math.max(parsed, SecondarySidebarComponent.MIN_WIDTH),
|
||||||
|
SecondarySidebarComponent.MAX_WIDTH
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch { /* storage indisponible : on garde la valeur par défaut */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Début du resize — on active le flag et on désactive la sélection texte le temps du drag. */
|
||||||
|
startResize(event: MouseEvent): void {
|
||||||
|
if (this.isCollapsed) return;
|
||||||
|
event.preventDefault();
|
||||||
|
this.isResizing = true;
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
document.body.style.cursor = 'col-resize';
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:mousemove', ['$event'])
|
||||||
|
onResizeMove(event: MouseEvent): void {
|
||||||
|
if (!this.isResizing) return;
|
||||||
|
// La sidebar peut être précédée par la sidebar primaire : on calcule la largeur
|
||||||
|
// cible à partir du bord gauche du composant, pas de la fenêtre. Sinon le
|
||||||
|
// curseur et la poignée se désynchronisent.
|
||||||
|
const rect = this.elementRef.nativeElement.getBoundingClientRect();
|
||||||
|
const delta = event.clientX - rect.left;
|
||||||
|
const next = Math.min(
|
||||||
|
Math.max(delta, SecondarySidebarComponent.MIN_WIDTH),
|
||||||
|
SecondarySidebarComponent.MAX_WIDTH
|
||||||
|
);
|
||||||
|
this.width = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:mouseup')
|
||||||
|
onResizeEnd(): void {
|
||||||
|
if (!this.isResizing) return;
|
||||||
|
this.isResizing = false;
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SecondarySidebarComponent.WIDTH_STORAGE_KEY, String(this.width));
|
||||||
|
} catch { /* storage indisponible : on ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
// Sécurité : si le composant est détruit en plein drag, on restaure le curseur global.
|
||||||
|
if (this.isResizing) {
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
runAction(action: SidebarAction): void {
|
runAction(action: SidebarAction): void {
|
||||||
if (action.route) { this.router.navigate([action.route]); }
|
if (action.route) { this.router.navigate([action.route]); }
|
||||||
@@ -80,6 +149,12 @@ export class SecondarySidebarComponent {
|
|||||||
if (item.route) { this.router.navigate([item.route]); }
|
if (item.route) { this.router.navigate([item.route]); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Clic sur le "+" du header : navigue sans toggler le panneau (stopPropagation). */
|
||||||
|
runPanelHeaderAction(event: Event, action: { route: string }): void {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.router.navigate([action.route]);
|
||||||
|
}
|
||||||
|
|
||||||
/** Résout la clé d'icône d'un TreeItem en icône lucide pour le template. */
|
/** Résout la clé d'icône d'un TreeItem en icône lucide pour le template. */
|
||||||
iconFor(item: TreeItem): LucideIconData | null {
|
iconFor(item: TreeItem): LucideIconData | null {
|
||||||
return item.iconKey ? resolveIcon(item.iconKey) : null;
|
return item.iconKey ? resolveIcon(item.iconKey) : null;
|
||||||
@@ -108,12 +183,9 @@ export class SecondarySidebarComponent {
|
|||||||
return !!item.children && item.children.length > 0;
|
return !!item.children && item.children.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** True si le chevron doit s'afficher — seulement quand le noeud a de vrais enfants. */
|
||||||
* True si le chevron doit s'afficher : soit il y a des enfants, soit le
|
|
||||||
* noeud a des createActions (dans ce cas deplier revele l'empty-state).
|
|
||||||
*/
|
|
||||||
isExpandable(item: TreeItem): boolean {
|
isExpandable(item: TreeItem): boolean {
|
||||||
return this.hasChildren(item) || (item.createActions?.length ?? 0) > 0;
|
return this.hasChildren(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user