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:
2026-04-23 11:25:58 +02:00
parent 29978058ee
commit 84ccdd53ad
20 changed files with 463 additions and 110 deletions

View File

@@ -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()">
<lucide-icon [img]="isCollapsed ? PanelLeftOpen : PanelLeftClose" [size]="16"></lucide-icon>
@@ -61,42 +63,38 @@
[title]="a.label"
[attr.aria-label]="a.label"
(click)="runCreateAction($event, a)">
<lucide-icon [img]="iconForAction(a)" [size]="12"></lucide-icon>
<lucide-icon [img]="iconForAction(a)" [size]="16"></lucide-icon>
</button>
</span>
</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 *ngTemplateOutlet="treeNode; context: { $implicit: child, level: level + 1 }"></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>
</ng-template>
<!-- Panneau bas (ex: Templates) ------------------------------------ -->
<section class="bottom-panel" *ngIf="bottomPanel">
<button class="panel-header" (click)="togglePanel()">
<span class="panel-title">{{ bottomPanel.title }}</span>
<lucide-icon
[img]="panelOpen ? ChevronDown : ChevronRight"
[size]="14">
</lucide-icon>
</button>
<div class="panel-header-row">
<button class="panel-header" (click)="togglePanel()">
<span class="panel-title">{{ bottomPanel.title }}</span>
<lucide-icon
[img]="panelOpen ? ChevronDown : ChevronRight"
[size]="14">
</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">
<li *ngFor="let item of bottomPanel.items">
<button
@@ -112,4 +110,9 @@
</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>

View File

@@ -8,15 +8,33 @@
padding: 1.25rem 0.75rem;
gap: 0.75rem;
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 {
width: 44px;
width: 44px !important;
padding: 1.25rem 0.5rem;
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 {
display: flex;
align-items: center;
@@ -142,7 +160,7 @@
.node-actions {
display: inline-flex;
align-items: center;
gap: 0.1rem;
gap: 0.2rem;
margin-left: auto;
flex-shrink: 0;
opacity: 0;
@@ -160,17 +178,17 @@
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
background: transparent;
width: 28px;
height: 28px;
background: rgba(55, 65, 81, 0.6);
border: none;
border-radius: 4px;
border-radius: 6px;
cursor: pointer;
color: #9ca3af;
color: #d1d5db;
padding: 0;
transition: background 0.12s, color 0.12s;
&:hover { background: #2a2a3d; color: #c7d2fe; }
&:hover { background: #4338ca; color: #ffffff; }
}
.chevron-btn {
@@ -214,11 +232,36 @@
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 {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
flex: 1;
min-width: 0;
background: transparent;
border: none;
color: #a5b4fc;

View File

@@ -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 { Router } from '@angular/router';
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',
styleUrls: ['./secondary-sidebar.component.scss']
})
export class SecondarySidebarComponent {
export class SecondarySidebarComponent implements OnDestroy {
@Input() title = '';
@Input() createActions: SidebarAction[] = [];
@Input() bottomPanel: BottomPanel | null = null;
@@ -31,6 +31,17 @@ export class SecondarySidebarComponent {
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[] = [];
@Input() set items(value: TreeItem[]) {
@@ -39,7 +50,65 @@ export class SecondarySidebarComponent {
}
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 {
if (action.route) { this.router.navigate([action.route]); }
@@ -80,6 +149,12 @@ export class SecondarySidebarComponent {
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. */
iconFor(item: TreeItem): LucideIconData | null {
return item.iconKey ? resolveIcon(item.iconKey) : null;
@@ -108,12 +183,9 @@ export class SecondarySidebarComponent {
return !!item.children && item.children.length > 0;
}
/**
* 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).
*/
/** True si le chevron doit s'afficher — seulement quand le noeud a de vrais enfants. */
isExpandable(item: TreeItem): boolean {
return this.hasChildren(item) || (item.createActions?.length ?? 0) > 0;
return this.hasChildren(item);
}
/**