Nettoyage post-revue : factorisation, code mort et fiabilité (reorder/lore/foundry/PDF)
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m27s
Build & Push Images / build (web) (push) Successful in 1m50s
Build & Push Images / build (core) (push) Successful in 3m29s
Build & Push Images / build-switcher (push) Successful in 18s

Corrections fonctionnelles
- order initialisé à la création des pages et dossiers de lore (nextOrderFor) :
  un nouvel élément se place désormais en dernier de sa fratrie au lieu de 0
- storePortrait : type MIME canonique dérivé de l'extension du fichier
  (un data URL "image/jpg" n'est plus rejeté silencieusement)
- takeUntilDestroyed ajouté sur les abonnements paramMap de arc-view,
  folder-view et campaign-detail (fuites mémoire)

Factorisation (suppression de duplication)
- shared/folder-grouping.util.ts : groupByFolder + byOrder + byFolderName
  mutualisés entre npc-list, enemy-list, campaign-detail et la sidebar
  (folderChildren). Le tri des dossiers est désormais cohérent entre la
  sidebar et les vues cartes (insensible casse/accents partout)
- DataSyncService.onChange()/persist() remplacent le câblage
  changed$ + reorder recopié dans 5 vues
- campaign-detail : loadCampaignBundle()/applyCampaignBundle() éliminent
  le forkJoin dupliqué entre ngOnInit et reload
- ReorderSupport (domain/shared) : squelette générique remplaçant les
  8 boucles de réordonnancement copiées dans les services
- FoundryExportService : GameSystem résolu une seule fois dans buildBundle
- module Foundry : walkScalars mutualise la récursion de flattenStats et
  flattenStructure (comportements préservés) ; esc() de l'importer aligné
  sur foundry.utils.escapeHTML

Suppression de code mort
- characterService/characters retirés de loadCampaignTreeData et de ses
  16 appelants (arguments, injections, imports et littéraux CampaignTreeData)
- CSS orphelin : .tree-row.cdk-drop-list-receiving et .btn-back
- BottomPanel.initiallyOpen (jamais lu) retiré de l'interface et de l'appelant
- commentaire trompeur du PDF corrigé

Tests
- mise à jour de 3 tests obsolètes de campaign-tree.helper.spec qui
  vérifiaient encore l'ancien comportement (tri alphabétique, non-classés
  en enfants directs) → alignés sur le modèle actuel (tri par order +
  pseudo-dossier "Sans dossier")
This commit is contained in:
2026-06-26 09:10:20 +02:00
parent 3896c5b4cb
commit a9242ba1e1
94 changed files with 2234 additions and 546 deletions

View File

@@ -0,0 +1,46 @@
/**
* Regroupement par dossier + tri par ordre manuel, mutualisé entre toutes les
* vues ordonnables (listes PNJ/ennemis, grille de la page campagne, sidebar).
* Une seule définition du tri des dossiers évite que la sidebar et les cartes
* divergent (cf. revue de propreté).
*/
/** Comparateur d'ordre manuel (`order` croissant, défaut 0). */
export const byOrder = (a: { order?: number }, b: { order?: number }): number =>
(a.order ?? 0) - (b.order ?? 0);
/** Comparateur de noms de dossier : alphabétique, insensible casse/accents (comme la sidebar). */
export const byFolderName = (a: string, b: string): number =>
a.localeCompare(b, 'fr', { sensitivity: 'base' });
/** Un dossier (`folder`, `''` = non-classé) et ses éléments, déjà triés par ordre. */
export interface FolderGroup<T> {
folder: string;
items: T[];
}
/**
* Groupe `items` par dossier : dossiers triés alphabétiquement, éléments
* non-classés (folder vide) regroupés en dernier. Chaque groupe est trié par
* `order`. Renvoie une structure neutre (`{ folder, items }`) que chaque vue
* adapte à son besoin (cartes, arbre…).
*/
export function groupByFolder<T extends { order?: number; folder?: string | null }>(items: T[]): FolderGroup<T>[] {
const sorted = [...items].sort(byOrder);
const byFolder = new Map<string, T[]>();
const ungrouped: T[] = [];
for (const it of sorted) {
const f = (it.folder ?? '').trim();
if (f) {
if (!byFolder.has(f)) byFolder.set(f, []);
byFolder.get(f)!.push(it);
} else {
ungrouped.push(it);
}
}
const groups: FolderGroup<T>[] = [...byFolder.keys()]
.sort(byFolderName)
.map(folder => ({ folder, items: byFolder.get(folder)! }));
if (ungrouped.length) groups.push({ folder: '', items: ungrouped });
return groups;
}

View File

@@ -33,75 +33,33 @@
}
</div>
}
<!-- cdkDropListGroup : connecte toutes les drop lists de l'arbre (déplacement d'un
nœud d'une liste à l'autre). Chaque ligne est un <app-sidebar-tree-node> :
comme c'est un VRAI composant déclaré statiquement dans la drop list, son
cdkDrag résout sa drop list par DI (impossible via un template outlet-é). -->
<div class="tree">
@for (item of items; track item) {
<ng-container *ngTemplateOutlet="treeNode; context: { $implicit: item, level: 0 }"></ng-container>
}
</div>
<!-- Template récursif : un noeud d'arbre rend son bouton, puis ses enfants via ce même template -->
<ng-template #treeNode let-item let-level="level">
@if (level === 0 && item.sectionHeaderBefore) {
<div class="tree-section-header">
{{ item.sectionHeaderBefore }}
</div>
}
<div class="tree-item" [style.padding-left.px]="level * 12">
<div class="tree-row">
@if (!item.isAction && isExpandable(item)) {
<button
type="button"
class="chevron-btn"
(click)="clickChevron($event, item)">
<lucide-icon
[img]="isExpanded(item.id) ? ChevronDown : ChevronRight"
[size]="12">
</lucide-icon>
</button>
<div class="tree-list" cdkDropList
id="sidebar-root"
[cdkDropListData]="rootDragData"
[cdkDropListConnectedTo]="dropListIds"
[cdkDropListDisabled]="!dndEnabled()"
[cdkDropListEnterPredicate]="rootEnterPredicate"
(cdkDropListDropped)="onDrop(null, $event)">
@for (item of items; track item.id; let first = $first) {
<!-- En-tête de section RENDU HORS du nœud (sinon il serait emporté par le
drag du premier item). C'est un élément non-déplaçable de la drop list. -->
@if (item.sectionHeaderBefore) {
<div class="tree-section-header" [class.first]="first">{{ item.sectionHeaderBefore }}</div>
}
@if (item.isAction || !isExpandable(item)) {
<span class="chevron-spacer"></span>
@if (dndEnabled() && item.dragKind) {
<app-sidebar-tree-node cdkDrag [cdkDragData]="item" cdkDragLockAxis="y"
[item]="item" [level]="0"></app-sidebar-tree-node>
} @else {
<app-sidebar-tree-node [item]="item" [level]="0"></app-sidebar-tree-node>
}
<button type="button" class="tree-btn"
[class.action]="item.isAction"
[class.active]="isActive(item)"
(click)="clickItem(item)">
@if (iconFor(item); as icon) {
<lucide-icon
[img]="icon"
[size]="14"
class="item-icon">
</lucide-icon>
}
{{ item.label }}
@if (!item.isAction && item.meta) {
<span class="tree-item-meta">{{ item.meta }}</span>
}
</button>
<!-- Actions de creation contextuelles, revelees au survol de la ligne -->
@if (item.createActions?.length) {
<span class="node-actions">
@for (a of item.createActions; track a) {
<button
type="button"
class="node-action-btn"
[title]="a.label"
[attr.aria-label]="a.label"
(click)="runCreateAction($event, a)">
<lucide-icon [img]="iconForAction(a)" [size]="16"></lucide-icon>
</button>
}
</span>
}
</div>
@if (isExpanded(item.id) && hasChildren(item)) {
<div class="tree-children">
@for (child of item.children; track child) {
<ng-container *ngTemplateOutlet="treeNode; context: { $implicit: child, level: level + 1 }"></ng-container>
}
</div>
}
</div>
</ng-template>
</div>
<!-- Panneau bas (ex: Templates) ------------------------------------ -->
@if (bottomPanel) {
<section class="bottom-panel">

View File

@@ -50,23 +50,6 @@
&:hover { color: white; background: #1f2937; }
}
.btn-back {
display: flex;
align-items: center;
gap: 0.4rem;
background: transparent;
border: none;
color: #6b7280;
font-size: 0.8rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 6px;
transition: color 0.2s;
width: fit-content;
&:hover { color: white; }
}
.sidebar-title {
font-size: 1rem;
font-weight: 700;
@@ -132,153 +115,31 @@
.tree {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.15rem;
overflow-y: auto;
padding: 0.25rem 0;
}
.tree-list {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
// NB : les styles d'une LIGNE d'arbre (tree-row, tree-btn, chevron, dossier, poignée…)
// vivent désormais dans sidebar-tree-node.component.scss (composant récursif).
// En-tête de section — groupe visuellement les nœuds racines (ex: Personnages / Narration).
// Un filet au-dessus crée la séparation ; pas de filet pour la première section
// (le titre suffit) — on cible ça via :not(:first-child).
// En-tête de section (NARRATION / PERSONNAGES / OUTILS) — rendu ici, hors des nœuds.
.tree-section-header {
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #9ca3af;
padding: 0.6rem 0.5rem 0.3rem;
}
.tree > .tree-section-header:not(:first-child) {
border-top: 1px solid #374151;
margin-top: 0.35rem;
padding-top: 0.6rem;
padding: 0.6rem 0.5rem 0.3rem;
&.first { border-top: none; margin-top: 0; padding-top: 0.3rem; }
}
.tree-btn {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
background: transparent;
border: none;
color: #d1d5db;
font-size: 0.82rem;
padding: 0.4rem 0.5rem;
border-radius: 6px;
cursor: pointer;
text-align: left;
transition: background 0.15s;
&:hover { background: #1f2937; }
&.action { color: #6b7280; font-style: italic; }
&.action:hover { color: #a5b4fc; background: #1f2937; }
// Dossier / page / scène actuellement affichée : surligné avec un accent
// violet et une barre gauche pour repérer instantanément où on se trouve,
// utile quand plusieurs entrées partagent le même label.
&.active {
background: #1e1b4b;
color: white;
font-weight: 500;
box-shadow: inset 3px 0 0 #6c63ff;
}
&.active:hover { background: #2a2558; }
.tree-item-meta {
margin-left: auto;
font-size: 0.72rem;
color: #6b7280;
flex-shrink: 0;
}
}
.item-icon {
display: inline-flex;
align-items: center;
color: #a5b4fc;
flex-shrink: 0;
margin-right: 0.1rem;
}
.tree-row {
display: flex;
align-items: center;
gap: 0.15rem;
width: 100%;
position: relative;
// Hover-reveal : les boutons d'action de creation n'apparaissent qu'au
// survol de la ligne. Pattern Notion/VS Code.
&:hover .node-actions { opacity: 1; pointer-events: auto; }
}
.node-actions {
display: inline-flex;
align-items: center;
gap: 0.2rem;
margin-left: auto;
flex-shrink: 0;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
// Superpose les boutons d'action sur le .tree-item-meta eventuel
// (compteur de pages) : au hover on affiche les actions, au repos le meta.
position: absolute;
right: 0.25rem;
background: linear-gradient(to right, transparent, #1f2937 30%);
padding-left: 1rem;
}
.node-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: rgba(55, 65, 81, 0.6);
border: none;
border-radius: 6px;
cursor: pointer;
color: #d1d5db;
padding: 0;
transition: background 0.12s, color 0.12s;
&:hover { background: #4338ca; color: #ffffff; }
}
.chevron-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
background: transparent;
border: none;
border-radius: 3px;
cursor: pointer;
color: #6b7280;
padding: 0;
&:hover { background: #374151; color: white; }
}
.chevron-spacer {
display: inline-block;
width: 18px;
height: 18px;
flex-shrink: 0;
}
.tree-children {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
/* --- Bottom panel (ex: Templates) ------------------------------------- */
.bottom-panel {
border-top: 1px solid #1e1e3a;
@@ -372,3 +233,7 @@
flex-shrink: 0;
}
}
// NB : aperçu/placeholder = styles GLOBAUX (src/styles.scss), car l'aperçu CDK est
// déplacé hors du composant. Ici, juste le retour visuel de la drop list RACINE.
.tree-list.cdk-drop-list-receiving { background: rgba(138, 123, 200, 0.08); border-radius: 4px; }

View File

@@ -1,14 +1,17 @@
import { Component, Input, Output, EventEmitter, HostListener, OnDestroy, ElementRef } from '@angular/core';
import { Component, Input, Output, EventEmitter, HostListener, OnDestroy, ElementRef, forwardRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { CdkDropList, CdkDrag, CdkDragDrop, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
import { LucideAngularModule, ChevronRight, ChevronDown, PanelLeftClose, PanelLeftOpen, Plus, FolderPlus, FilePlus, Home, LucideIconData } from 'lucide-angular';
import { TreeItem, TreeCreateAction, SidebarAction, BottomPanel, BottomPanelItem, LayoutService } from '../../services/layout.service';
import { TreeItem, TreeCreateAction, SidebarAction, BottomPanel, BottomPanelItem, LayoutService, ReorderKind, SidebarReorderContext } from '../../services/layout.service';
import { SidebarReorderService } from '../../services/sidebar-reorder.service';
import { SidebarTreeNodeComponent } from './sidebar-tree-node.component';
import { resolveIcon } from '../../lore/lore-icons';
@Component({
selector: 'app-secondary-sidebar',
imports: [CommonModule, LucideAngularModule, TranslatePipe],
imports: [CommonModule, LucideAngularModule, TranslatePipe, CdkDropList, CdkDrag, forwardRef(() => SidebarTreeNodeComponent)],
templateUrl: './secondary-sidebar.component.html',
styleUrls: ['./secondary-sidebar.component.scss']
})
@@ -18,6 +21,10 @@ export class SecondarySidebarComponent implements OnDestroy {
@Input() titleRoute: string | null = null;
@Input() createActions: SidebarAction[] = [];
@Input() bottomPanel: BottomPanel | null = null;
/** DnD : types déplaçables à la racine, parent racine, et contexte de rechargement. */
@Input() rootDropKinds: ReorderKind[] | null = null;
@Input() rootDropParentId: string | null = null;
@Input() reorderContext: SidebarReorderContext | null = null;
@Output() collapsedChange = new EventEmitter<boolean>();
/** true = ouvert (on affiche les items) ; false = replié (titre seul). */
@@ -47,16 +54,35 @@ export class SecondarySidebarComponent implements OnDestroy {
private _items: TreeItem[] = [];
// Données de drag STABLES (références constantes entre détections de changement).
// Indispensable : recalculer le tableau à chaque CD réinitialiserait le tri CDK en
// plein drag (l'élément flotterait sans s'insérer). Reconstruites au set d'items.
private _dragData = new Map<string, TreeItem[]>();
private _predicates = new Map<string, (drag: CdkDrag) => boolean>();
private _nodesById = new Map<string, TreeItem>();
rootDragData: TreeItem[] = [];
/** Ids de TOUTES les drop lists, pour les connecter explicitement (registre global
* CDK → fonctionne à travers les composants, contrairement à cdkDropListGroup). */
dropListIds: string[] = ['sidebar-root'];
@Input() set items(value: TreeItem[]) {
this._items = value ?? [];
this._buildDragMaps();
this.autoExpandActiveAncestors();
}
get items(): TreeItem[] { return this._items; }
/** Prédicat racine (stable) : lit `rootDropKinds` au moment de l'appel. */
rootEnterPredicate = (drag: CdkDrag): boolean => {
const k = (drag.data as TreeItem | undefined)?.dragKind;
return !!k && !!this.rootDropKinds && this.rootDropKinds.includes(k);
};
constructor(
private router: Router,
private layoutService: LayoutService,
private elementRef: ElementRef<HTMLElement>
private elementRef: ElementRef<HTMLElement>,
private reorderService: SidebarReorderService
) {
try {
const stored = localStorage.getItem(SecondarySidebarComponent.WIDTH_STORAGE_KEY);
@@ -199,6 +225,109 @@ export class SecondarySidebarComponent implements OnDestroy {
return !!item.children && item.children.length > 0;
}
// --- Glisser-déposer (réordonnancement dans l'arbre) --------------------
/** DnD actif uniquement si un contexte de rechargement est fourni (campagne/lore). */
dndEnabled(): boolean {
return !!this.reorderContext;
}
/**
* Reconstruit, pour chaque nœud, son tableau d'enfants déplaçables et son prédicat
* de dépôt — une seule fois (au set d'items) pour fournir des références STABLES.
* Filtrer les non-déplaçables garde les indices CDK alignés sur la donnée.
*/
private _buildDragMaps(): void {
this._dragData.clear();
this._predicates.clear();
this._nodesById.clear();
this.dropListIds = ['sidebar-root'];
this.rootDragData = this._items.filter(i => !!i.dragKind);
const walk = (nodes: TreeItem[]): void => {
for (const n of nodes) {
this._nodesById.set(n.id, n);
if (n.dropKinds) {
this._predicates.set(n.id, this._makePredicate(n.dropKinds));
this.dropListIds.push('lst-' + n.id);
}
if (n.children?.length) {
this._dragData.set(n.id, n.children.filter(c => !!c.dragKind));
walk(n.children);
}
}
};
walk(this._items);
}
private _makePredicate(kinds: ReorderKind[] | null): (drag: CdkDrag) => boolean {
return (drag: CdkDrag): boolean => {
const k = (drag.data as TreeItem | undefined)?.dragKind;
return !!k && !!kinds && kinds.includes(k);
};
}
/** Données (stables) de la drop list des enfants d'un nœud. */
dragDataFor(item: TreeItem): TreeItem[] {
return this._dragData.get(item.id) ?? [];
}
/** Prédicat (stable) de la drop list des enfants d'un nœud. */
enterPredicateFor(item: TreeItem): (drag: CdkDrag) => boolean {
return this._predicates.get(item.id) ?? (() => false);
}
/**
* Dépôt d'un élément. Le POINT DE LÂCHER fait autorité (override fiable de la
* détection de conteneur de CDK, qui est ambiguë avec des listes imbriquées) :
* - lâché sur un AUTRE dossier (compatible) → déplacement DANS ce dossier ;
* - sinon → réordonnancement dans la liste courante.
* Puis on persiste et on recharge depuis le backend (source de vérité).
*/
onDrop(parent: TreeItem | null, event: CdkDragDrop<TreeItem[]>): void {
if (!this.reorderContext) return;
const dragged = event.item.data as TreeItem;
const kind = dragged?.dragKind;
if (!kind || !dragged.dragId) return;
// 1) Le curseur est-il sur un dossier compatible, DIFFÉRENT du dossier d'origine ?
const folder = this.folderAtPoint(event.dropPoint, kind);
const currentParent = parent ? (parent.dropParentId ?? null) : (this.rootDropParentId ?? null);
if (folder && (folder.dropParentId ?? null) !== currentParent) {
this.moveIntoFolder(dragged, kind, folder);
return;
}
// 2) Sinon : réordonnancement (même liste) ou déplacement géré par CDK (autre liste).
const target = event.container.data;
if (event.previousContainer === event.container) {
if (event.previousIndex === event.currentIndex) return;
moveItemInArray(target, event.previousIndex, event.currentIndex);
} else {
transferArrayItem(event.previousContainer.data, target, event.previousIndex, event.currentIndex);
}
const orderedIds = target.filter(i => i.dragKind === kind).map(i => i.dragId!).filter(Boolean);
this.reorderService.reorder(this.reorderContext, kind, currentParent, orderedIds);
}
/** Dossier compatible sous le point de lâcher (via le DOM réel), ou null. */
private folderAtPoint(pt: { x: number; y: number } | undefined, kind: ReorderKind): TreeItem | null {
if (!pt) return null;
const el = document.elementFromPoint(pt.x, pt.y) as HTMLElement | null;
const nodeEl = el?.closest('[data-drop-node]') as HTMLElement | null;
const id = nodeEl?.getAttribute('data-drop-node');
const node = id ? this._nodesById.get(id) : undefined;
return node && node.dropKinds?.includes(kind) ? node : null;
}
/** Déplace l'élément à la FIN d'un dossier cible (déplacement inter-dossiers). */
private moveIntoFolder(dragged: TreeItem, kind: ReorderKind, folder: TreeItem): void {
const existing = (this._dragData.get(folder.id) ?? [])
.filter(i => i.dragKind === kind)
.map(i => i.dragId!)
.filter(id => id && id !== dragged.dragId);
this.reorderService.reorder(this.reorderContext!, kind, folder.dropParentId ?? null, [...existing, dragged.dragId!]);
}
/** True si le chevron doit s'afficher — seulement quand le noeud a de vrais enfants. */
isExpandable(item: TreeItem): boolean {
return this.hasChildren(item);

View File

@@ -0,0 +1,64 @@
<div class="tree-row" [style.padding-left.px]="level * 12">
@if (sidebar.dndEnabled() && item.dragKind) {
<span class="drag-handle" cdkDragHandle [title]="'secondarySidebar.dragHandle' | translate">
<lucide-icon [img]="GripVertical" [size]="12"></lucide-icon>
</span>
}
@if (!item.isAction && sidebar.isExpandable(item)) {
<button type="button" class="chevron-btn" (click)="sidebar.clickChevron($event, item)">
<lucide-icon [img]="sidebar.isExpanded(item.id) ? ChevronDown : ChevronRight" [size]="12"></lucide-icon>
</button>
}
@if (item.isAction || !sidebar.isExpandable(item)) {
<span class="chevron-spacer"></span>
}
<button type="button" class="tree-btn"
[class.action]="item.isAction"
[class.active]="sidebar.isActive(item)"
(click)="sidebar.clickItem(item)">
@if (sidebar.iconFor(item); as icon) {
<lucide-icon [img]="icon" [size]="14" class="item-icon"></lucide-icon>
}
{{ item.label }}
@if (!item.isAction && item.meta) {
<span class="tree-item-meta">{{ item.meta }}</span>
}
</button>
@if (item.createActions?.length) {
<span class="node-actions">
@for (a of item.createActions; track a) {
<button type="button" class="node-action-btn"
[title]="a.label" [attr.aria-label]="a.label"
(click)="sidebar.runCreateAction($event, a)">
<lucide-icon [img]="sidebar.iconForAction(a)" [size]="16"></lucide-icon>
</button>
}
</span>
}
</div>
@if (sidebar.isExpanded(item.id) && sidebar.hasChildren(item)) {
@if (sidebar.dndEnabled() && item.dropKinds) {
<div class="tree-children" cdkDropList
[id]="'lst-' + item.id"
[cdkDropListData]="sidebar.dragDataFor(item)"
[cdkDropListConnectedTo]="sidebar.dropListIds"
[cdkDropListEnterPredicate]="sidebar.enterPredicateFor(item)"
(cdkDropListDropped)="sidebar.onDrop(item, $event)">
@for (child of item.children; track child.id) {
@if (sidebar.dndEnabled() && child.dragKind) {
<app-sidebar-tree-node cdkDrag [cdkDragData]="child" cdkDragLockAxis="y"
[item]="child" [level]="level + 1"></app-sidebar-tree-node>
} @else {
<app-sidebar-tree-node [item]="child" [level]="level + 1"></app-sidebar-tree-node>
}
}
</div>
} @else {
<div class="tree-children">
@for (child of item.children; track child.id) {
<app-sidebar-tree-node [item]="child" [level]="level + 1"></app-sidebar-tree-node>
}
</div>
}
}

View File

@@ -0,0 +1,138 @@
// Le nœud est l'élément déplaçable (cdkDrag) → il doit être un bloc dans la colonne.
// (Les en-têtes de section sont rendus HORS du nœud, dans la sidebar, pour qu'ils ne
// soient pas emportés quand on déplace le premier item d'une section.)
:host { display: block; }
.tree-row {
display: flex;
align-items: center;
gap: 0.15rem;
width: 100%;
position: relative;
// Hover-reveal : les actions de création n'apparaissent qu'au survol (Notion/VS Code).
&:hover .node-actions { opacity: 1; pointer-events: auto; }
}
.tree-btn {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
background: transparent;
border: none;
color: #d1d5db;
font-size: 0.82rem;
padding: 0.4rem 0.5rem;
border-radius: 6px;
cursor: pointer;
text-align: left;
transition: background 0.15s;
&:hover { background: #1f2937; }
&.action { color: #6b7280; font-style: italic; }
&.action:hover { color: #a5b4fc; background: #1f2937; }
&.active {
background: #1e1b4b;
color: white;
font-weight: 500;
box-shadow: inset 3px 0 0 #6c63ff;
}
&.active:hover { background: #2a2558; }
.tree-item-meta {
margin-left: auto;
font-size: 0.72rem;
color: #6b7280;
flex-shrink: 0;
}
}
.item-icon {
display: inline-flex;
align-items: center;
color: #a5b4fc;
flex-shrink: 0;
margin-right: 0.1rem;
}
.node-actions {
display: inline-flex;
align-items: center;
gap: 0.2rem;
margin-left: auto;
flex-shrink: 0;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
position: absolute;
right: 0.25rem;
background: linear-gradient(to right, transparent, #1f2937 30%);
padding-left: 1rem;
}
.node-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: rgba(55, 65, 81, 0.6);
border: none;
border-radius: 6px;
cursor: pointer;
color: #d1d5db;
padding: 0;
transition: background 0.12s, color 0.12s;
&:hover { background: #4338ca; color: #ffffff; }
}
.chevron-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
background: transparent;
border: none;
border-radius: 3px;
cursor: pointer;
color: #6b7280;
padding: 0;
&:hover { background: #374151; color: white; }
}
.chevron-spacer {
display: inline-block;
width: 18px;
height: 18px;
flex-shrink: 0;
}
.tree-children {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
// Zone de dépôt qui reçoit un élément valide : léger fond pour guider l'œil.
.tree-children.cdk-drop-list-receiving { background: rgba(138, 123, 200, 0.08); border-radius: 4px; }
// Poignée de glisser-déposer : faible au repos, visible au survol de la ligne.
.drag-handle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
flex-shrink: 0;
margin-right: 1px;
color: #6b7280;
opacity: 0.25;
cursor: grab;
transition: opacity 0.15s, color 0.15s;
}
.tree-row:hover .drag-handle { opacity: 0.9; color: #8a7bc8; }
.drag-handle:active { cursor: grabbing; }

View File

@@ -0,0 +1,49 @@
import { Component, Input, HostBinding, forwardRef, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TranslatePipe } from '@ngx-translate/core';
import { CdkDropList, CdkDrag, CdkDragHandle } from '@angular/cdk/drag-drop';
import { LucideAngularModule, ChevronRight, ChevronDown, GripVertical } from 'lucide-angular';
import { TreeItem } from '../../services/layout.service';
import { SecondarySidebarComponent } from './secondary-sidebar.component';
/**
* Un nœud de l'arbre de la sidebar (récursif).
*
* Pourquoi un COMPOSANT récursif et pas un `*ngTemplateOutlet` ? Parce que `cdkDrag`
* résout sa drop list par INJECTION au lieu de DÉCLARATION du template : un template
* outlet-é se résout au site de DÉCLARATION (hors des drop lists) → le drag n'a aucun
* conteneur → aucun réordonnancement (l'élément flotte). En rendant chaque enfant via
* `<app-sidebar-tree-node cdkDrag>` STATIQUEMENT dans la drop list du parent, le DI
* trouve la bonne liste et le tri/insertion fonctionne.
*
* Toute la logique (clic, expand, icônes, DnD) est déléguée au composant sidebar parent
* (injecté) pour éviter la duplication.
*/
@Component({
selector: 'app-sidebar-tree-node',
imports: [
CommonModule, LucideAngularModule, TranslatePipe,
CdkDropList, CdkDrag, CdkDragHandle,
forwardRef(() => SidebarTreeNodeComponent)
],
templateUrl: './sidebar-tree-node.component.html',
styleUrls: ['./sidebar-tree-node.component.scss']
})
export class SidebarTreeNodeComponent {
@Input({ required: true }) item!: TreeItem;
@Input() level = 0;
/** Marque l'hôte comme dossier (cible de dépôt détectée par position de lâcher).
* Posé sur l'HÔTE = ancêtre du header ET des enfants → un drop n'importe où sur le
* dossier le résout correctement. */
@HostBinding('attr.data-drop-node') get dropNodeAttr(): string | null {
return this.item?.dropKinds ? this.item.id : null;
}
readonly ChevronRight = ChevronRight;
readonly ChevronDown = ChevronDown;
readonly GripVertical = GripVertical;
/** Sidebar parente (toujours un ancêtre DI) : on lui délègue toute la logique. */
readonly sidebar = inject(SecondarySidebarComponent);
}