Mise à jour avec la possibilité de mettre des images
This commit is contained in:
@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, X, Send, Sparkles, Lightbulb, Wand2 } from 'lucide-angular';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { AiChatService, ChatMessage } from '../../services/ai-chat.service';
|
||||
import { AiChatService, ChatMessage, NarrativeEntityType } from '../../services/ai-chat.service';
|
||||
|
||||
/**
|
||||
* Action primaire optionnelle rendue en gros bouton au-dessus des suggestions.
|
||||
@@ -42,6 +42,12 @@ export class AiChatDrawerComponent implements OnDestroy {
|
||||
readonly Lightbulb = Lightbulb;
|
||||
readonly Wand2 = Wand2;
|
||||
|
||||
/**
|
||||
* Mode Lore : fournir `loreId` (et optionnellement `pageId`).
|
||||
* Mode Campagne : fournir `campaignId` (et optionnellement `entityType`+`entityId`).
|
||||
* Les deux modes sont exclusifs — si `campaignId` est non-vide, on route
|
||||
* vers l'endpoint Campagne, sinon vers l'endpoint Lore.
|
||||
*/
|
||||
@Input() loreId = '';
|
||||
/**
|
||||
* Optionnel : ID d'une page précise en cours d'édition. Si fourni, le
|
||||
@@ -50,6 +56,13 @@ export class AiChatDrawerComponent implements OnDestroy {
|
||||
* reste générique au Lore.
|
||||
*/
|
||||
@Input() pageId: string | null = null;
|
||||
|
||||
/** ID de la Campagne — active le mode chat Campagne si non-vide. */
|
||||
@Input() campaignId: string | null = null;
|
||||
/** Optionnel : "arc"|"chapter"|"scene" — focalise l'IA sur une entité narrative. */
|
||||
@Input() entityType: NarrativeEntityType | null = null;
|
||||
/** Optionnel : ID de l'entité narrative en cours d'édition. */
|
||||
@Input() entityId: string | null = null;
|
||||
@Input() isOpen = false;
|
||||
/** Texte accueil affiché au premier ouverture (avant tout échange). */
|
||||
@Input() welcomeMessage = 'Bonjour ! Je peux vous aider à développer cette page. Que souhaitez-vous créer ?';
|
||||
@@ -131,7 +144,11 @@ export class AiChatDrawerComponent implements OnDestroy {
|
||||
? [{ role: 'system' as const, content: this.systemPromptAddon }, ...this.messages]
|
||||
: this.messages;
|
||||
|
||||
this.streamSub = this.chatService.streamChat(this.loreId, payload, this.pageId).subscribe({
|
||||
const stream$ = this.campaignId
|
||||
? this.chatService.streamChatForCampaign(this.campaignId, payload, this.entityType, this.entityId)
|
||||
: this.chatService.streamChat(this.loreId, payload, this.pageId);
|
||||
|
||||
this.streamSub = stream$.subscribe({
|
||||
next: (event) => {
|
||||
if (event.type === 'token') {
|
||||
this.currentAssistantText += event.value;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<!-- Grille de vignettes + uploader si editable. -->
|
||||
<div class="gallery"
|
||||
*ngIf="imageIds.length > 0 || editable; else empty">
|
||||
|
||||
<div class="gallery-tile"
|
||||
*ngFor="let id of imageIds"
|
||||
(click)="openLightbox(id)"
|
||||
role="button"
|
||||
tabindex="0">
|
||||
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
|
||||
|
||||
<button type="button"
|
||||
class="gallery-remove"
|
||||
*ngIf="editable"
|
||||
(click)="remove(id, $event)"
|
||||
aria-label="Retirer cette image">
|
||||
<lucide-icon [img]="X" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Bouton + (uploader compact), uniquement en mode edition -->
|
||||
<app-image-uploader
|
||||
*ngIf="editable"
|
||||
[compact]="true"
|
||||
(uploaded)="onUploaded($event)">
|
||||
</app-image-uploader>
|
||||
</div>
|
||||
|
||||
<!-- Etat vide (lecture uniquement). -->
|
||||
<ng-template #empty>
|
||||
<div class="gallery-empty">
|
||||
<lucide-icon [img]="ImageIcon" [size]="20"></lucide-icon>
|
||||
<span>Aucune illustration</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<!-- Lightbox : image plein ecran sur fond noir, clic pour fermer. -->
|
||||
<div class="lightbox-backdrop"
|
||||
*ngIf="lightboxId"
|
||||
(click)="closeLightbox()"
|
||||
role="dialog"
|
||||
aria-label="Image en plein ecran">
|
||||
<img [src]="urlFor(lightboxId)" alt="Image agrandie" class="lightbox-image" />
|
||||
<button type="button" class="lightbox-close" (click)="closeLightbox()" aria-label="Fermer">
|
||||
<lucide-icon [img]="X" [size]="24"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
107
web/src/app/shared/image-gallery/image-gallery.component.scss
Normal file
107
web/src/app/shared/image-gallery/image-gallery.component.scss
Normal file
@@ -0,0 +1,107 @@
|
||||
.gallery {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.8rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.gallery-tile {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
cursor: zoom-in;
|
||||
transition: border-color 0.15s, transform 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #6c63ff;
|
||||
transform: translateY(-2px);
|
||||
|
||||
.gallery-remove { opacity: 1; }
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-remove {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(17, 17, 30, 0.85);
|
||||
color: #fca5a5;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, background 0.15s;
|
||||
|
||||
&:hover { background: #7f1d1d; color: white; }
|
||||
}
|
||||
|
||||
.gallery-empty {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #4b5563;
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// Lightbox plein ecran
|
||||
.lightbox-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(0, 0, 0, 0.88);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
cursor: zoom-out;
|
||||
animation: fade-in 0.15s ease-out;
|
||||
}
|
||||
|
||||
.lightbox-image {
|
||||
max-width: 95vw;
|
||||
max-height: 90vh;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.lightbox-close {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(30, 30, 60, 0.8);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover { background: #6c63ff; }
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
76
web/src/app/shared/image-gallery/image-gallery.component.ts
Normal file
76
web/src/app/shared/image-gallery/image-gallery.component.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { LucideAngularModule, X, Image as ImageIcon } from 'lucide-angular';
|
||||
import { ImageService } from '../../services/image.service';
|
||||
import { Image } from '../../services/image.model';
|
||||
import { ImageUploaderComponent } from '../image-uploader/image-uploader.component';
|
||||
|
||||
/**
|
||||
* Composant reutilisable de galerie d'images.
|
||||
*
|
||||
* Deux modes :
|
||||
* - editable=false (defaut) : affichage en grille, clic sur une image ouvre
|
||||
* un lightbox plein ecran pour zoomer.
|
||||
* - editable=true : bouton "+ ajouter" en fin de grille (via app-image-uploader),
|
||||
* chaque vignette a un bouton "X" pour la retirer.
|
||||
*
|
||||
* La galerie raisonne sur une liste d'IDs d'images (string[]). Elle ne stocke
|
||||
* pas les objets Image eux-memes : les thumbs utilisent `imageService.contentUrl(id)`
|
||||
* directement comme src. Le navigateur cache les binaires via Cache-Control immutable
|
||||
* pose par le backend, donc aucune requete redondante.
|
||||
*
|
||||
* Usage :
|
||||
* <app-image-gallery [imageIds]="scene.illustrationImageIds"></app-image-gallery>
|
||||
* <app-image-gallery [imageIds]="tempIds" [editable]="true"
|
||||
* (imageIdsChange)="tempIds = $event"></app-image-gallery>
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-image-gallery',
|
||||
standalone: true,
|
||||
imports: [CommonModule, LucideAngularModule, ImageUploaderComponent],
|
||||
templateUrl: './image-gallery.component.html',
|
||||
styleUrls: ['./image-gallery.component.scss']
|
||||
})
|
||||
export class ImageGalleryComponent {
|
||||
readonly X = X;
|
||||
readonly ImageIcon = ImageIcon;
|
||||
|
||||
/** IDs d'images a afficher. */
|
||||
@Input() imageIds: string[] = [];
|
||||
|
||||
/** Mode edition : afficher le bouton d'ajout + les boutons de suppression. */
|
||||
@Input() editable = false;
|
||||
|
||||
/** Emet la nouvelle liste quand l'utilisateur ajoute/retire une image. */
|
||||
@Output() imageIdsChange = new EventEmitter<string[]>();
|
||||
|
||||
/** ID de l'image actuellement ouverte en lightbox (null = ferme). */
|
||||
lightboxId: string | null = null;
|
||||
|
||||
constructor(private imageService: ImageService) {}
|
||||
|
||||
/** URL absolue du binaire d'une image. */
|
||||
urlFor(id: string): string {
|
||||
return this.imageService.contentUrl(id);
|
||||
}
|
||||
|
||||
onUploaded(image: Image): void {
|
||||
this.imageIdsChange.emit([...this.imageIds, image.id]);
|
||||
}
|
||||
|
||||
remove(id: string, event: MouseEvent): void {
|
||||
event.stopPropagation(); // Evite d'ouvrir le lightbox en cliquant sur X.
|
||||
// On supprime aussi cote serveur pour ne pas laisser d'image orpheline.
|
||||
// Best-effort : on n'attend pas le retour pour emettre la nouvelle liste.
|
||||
this.imageService.delete(id).subscribe({ error: () => {} });
|
||||
this.imageIdsChange.emit(this.imageIds.filter(i => i !== id));
|
||||
}
|
||||
|
||||
openLightbox(id: string): void {
|
||||
this.lightboxId = id;
|
||||
}
|
||||
|
||||
closeLightbox(): void {
|
||||
this.lightboxId = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<!-- Mode compact : bouton "+ ajouter" carre, utilise dans les galeries. -->
|
||||
<ng-container *ngIf="compact; else dropZone">
|
||||
<label class="upload-compact" [class.loading]="uploading" [title]="errorMessage || 'Ajouter une image'">
|
||||
<ng-container *ngIf="!uploading; else spinner">
|
||||
<lucide-icon [img]="Upload" [size]="18"></lucide-icon>
|
||||
<span>Ajouter</span>
|
||||
</ng-container>
|
||||
<input type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
(change)="onFileSelected($event)"
|
||||
[disabled]="uploading"
|
||||
hidden />
|
||||
</label>
|
||||
<p *ngIf="errorMessage" class="upload-error-inline">
|
||||
<lucide-icon [img]="AlertCircle" [size]="12"></lucide-icon>
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</ng-container>
|
||||
|
||||
<!-- Mode standard : grande drop-zone cliquable. -->
|
||||
<ng-template #dropZone>
|
||||
<label class="upload-zone"
|
||||
[class.drag-over]="dragOver"
|
||||
[class.loading]="uploading"
|
||||
(dragover)="onDragOver($event)"
|
||||
(dragleave)="onDragLeave()"
|
||||
(drop)="onDrop($event)">
|
||||
|
||||
<ng-container *ngIf="!uploading; else spinner">
|
||||
<lucide-icon [img]="Upload" [size]="32"></lucide-icon>
|
||||
<p class="upload-zone-title">Glisse une image ici</p>
|
||||
<p class="upload-zone-hint">ou clique pour choisir un fichier (JPEG, PNG, WebP, GIF, max 10 Mo)</p>
|
||||
</ng-container>
|
||||
|
||||
<input type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
(change)="onFileSelected($event)"
|
||||
[disabled]="uploading"
|
||||
hidden />
|
||||
</label>
|
||||
|
||||
<p *ngIf="errorMessage" class="upload-error" role="alert">
|
||||
<lucide-icon [img]="AlertCircle" [size]="14"></lucide-icon>
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #spinner>
|
||||
<div class="upload-spinner" aria-label="Upload en cours"></div>
|
||||
<p class="upload-zone-hint">Upload en cours...</p>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,88 @@
|
||||
// Drop-zone standard
|
||||
.upload-zone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 140px;
|
||||
padding: 1.5rem;
|
||||
background: #1a1a2e;
|
||||
border: 2px dashed #2a2a3d;
|
||||
border-radius: 8px;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
|
||||
&:hover { border-color: #6c63ff; color: #d1d5db; }
|
||||
&.drag-over { border-color: #6c63ff; background: #1f1f3a; color: white; }
|
||||
&.loading { cursor: progress; opacity: 0.7; }
|
||||
}
|
||||
|
||||
.upload-zone-title {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.upload-zone-hint {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// Variante compacte (bouton carre pour galerie)
|
||||
.upload-compact {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: #1a1a2e;
|
||||
border: 2px dashed #2a2a3d;
|
||||
border-radius: 6px;
|
||||
color: #6b7280;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
|
||||
&:hover { border-color: #6c63ff; color: #a5b4fc; background: #1f1f3a; }
|
||||
&.loading { cursor: progress; opacity: 0.7; }
|
||||
}
|
||||
|
||||
.upload-error, .upload-error-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0.5rem 0 0;
|
||||
padding: 0.5rem 0.7rem;
|
||||
background: #3f1f1f;
|
||||
color: #fca5a5;
|
||||
border: 1px solid #7f1d1d;
|
||||
border-radius: 6px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.upload-error-inline {
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
// Spinner CSS simple (pas de dep externe)
|
||||
.upload-spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 3px solid #2a2a3d;
|
||||
border-top-color: #6c63ff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
100
web/src/app/shared/image-uploader/image-uploader.component.ts
Normal file
100
web/src/app/shared/image-uploader/image-uploader.component.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { LucideAngularModule, Upload, AlertCircle } from 'lucide-angular';
|
||||
import { ImageService } from '../../services/image.service';
|
||||
import { Image } from '../../services/image.model';
|
||||
|
||||
/**
|
||||
* Composant reutilisable d'upload d'image (drop-zone + bouton file).
|
||||
*
|
||||
* Usage :
|
||||
* <app-image-uploader (uploaded)="onImageUploaded($event)"></app-image-uploader>
|
||||
*
|
||||
* Responsabilites :
|
||||
* - Accepter un fichier via drag&drop OU clic sur la zone
|
||||
* - Valider cote client (MIME + taille) pour eviter un aller-retour inutile
|
||||
* - POSTer vers /api/images (service ImageService)
|
||||
* - Emettre (uploaded) avec l'objet Image recu
|
||||
* - Afficher l'etat loading et les erreurs
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-image-uploader',
|
||||
standalone: true,
|
||||
imports: [CommonModule, LucideAngularModule],
|
||||
templateUrl: './image-uploader.component.html',
|
||||
styleUrls: ['./image-uploader.component.scss']
|
||||
})
|
||||
export class ImageUploaderComponent {
|
||||
readonly Upload = Upload;
|
||||
readonly AlertCircle = AlertCircle;
|
||||
|
||||
/** Compact mode : bouton "+ ajouter" plutot que grande drop-zone. */
|
||||
@Input() compact = false;
|
||||
|
||||
/** Emit quand l'image est uploadee avec succes. */
|
||||
@Output() uploaded = new EventEmitter<Image>();
|
||||
|
||||
uploading = false;
|
||||
errorMessage: string | null = null;
|
||||
dragOver = false;
|
||||
|
||||
/** MIME types alignes avec le backend (ImageService.java). */
|
||||
private readonly ALLOWED_MIMES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
||||
private readonly MAX_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
constructor(private imageService: ImageService) {}
|
||||
|
||||
onFileSelected(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
this.handleFile(input.files[0]);
|
||||
// Reset pour permettre de re-uploader la meme image si besoin.
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
onDragOver(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
this.dragOver = true;
|
||||
}
|
||||
|
||||
onDragLeave(): void {
|
||||
this.dragOver = false;
|
||||
}
|
||||
|
||||
onDrop(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
this.dragOver = false;
|
||||
if (event.dataTransfer?.files && event.dataTransfer.files.length > 0) {
|
||||
this.handleFile(event.dataTransfer.files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
private handleFile(file: File): void {
|
||||
this.errorMessage = null;
|
||||
|
||||
// Validation cote client (premier filet de securite).
|
||||
if (!this.ALLOWED_MIMES.includes(file.type)) {
|
||||
this.errorMessage = 'Format non supporte (JPEG, PNG, WebP, GIF uniquement).';
|
||||
return;
|
||||
}
|
||||
if (file.size > this.MAX_BYTES) {
|
||||
this.errorMessage = `Fichier trop volumineux (max ${this.MAX_BYTES / 1024 / 1024} Mo).`;
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploading = true;
|
||||
this.imageService.upload(file).subscribe({
|
||||
next: (image) => {
|
||||
this.uploading = false;
|
||||
this.uploaded.emit(image);
|
||||
},
|
||||
error: (err) => {
|
||||
this.uploading = false;
|
||||
this.errorMessage = err?.status === 413
|
||||
? 'Fichier refuse par le serveur (trop volumineux).'
|
||||
: 'Echec de l\'upload. Verifiez que le backend et MinIO tournent.';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -28,25 +28,30 @@
|
||||
<!-- 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">
|
||||
<div class="tree-item" [style.padding-left.px]="level * 12">
|
||||
<button class="tree-btn" [class.action]="item.isAction" (click)="clickItem(item)">
|
||||
<span
|
||||
<div class="tree-row">
|
||||
<button
|
||||
*ngIf="!item.isAction && item.children?.length"
|
||||
class="chevron-zone"
|
||||
type="button"
|
||||
class="chevron-btn"
|
||||
(click)="clickChevron($event, item)">
|
||||
<lucide-icon
|
||||
[img]="isExpanded(item.id) ? ChevronDown : ChevronRight"
|
||||
[size]="12">
|
||||
</lucide-icon>
|
||||
</span>
|
||||
<lucide-icon
|
||||
*ngIf="iconFor(item) as icon"
|
||||
[img]="icon"
|
||||
[size]="14"
|
||||
class="item-icon">
|
||||
</lucide-icon>
|
||||
{{ item.label }}
|
||||
<span class="tree-item-meta" *ngIf="!item.isAction && item.meta">{{ item.meta }}</span>
|
||||
</button>
|
||||
</button>
|
||||
<span *ngIf="item.isAction || !item.children?.length" class="chevron-spacer"></span>
|
||||
|
||||
<button type="button" class="tree-btn" [class.action]="item.isAction" (click)="clickItem(item)">
|
||||
<lucide-icon
|
||||
*ngIf="iconFor(item) as icon"
|
||||
[img]="icon"
|
||||
[size]="14"
|
||||
class="item-icon">
|
||||
</lucide-icon>
|
||||
{{ item.label }}
|
||||
<span class="tree-item-meta" *ngIf="!item.isAction && item.meta">{{ item.meta }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tree-children" *ngIf="isExpanded(item.id) && item.children?.length">
|
||||
<ng-container *ngFor="let child of item.children">
|
||||
<ng-container *ngTemplateOutlet="treeNode; context: { $implicit: child, level: level + 1 }"></ng-container>
|
||||
|
||||
@@ -127,19 +127,37 @@
|
||||
margin-right: 0.1rem;
|
||||
}
|
||||
|
||||
.chevron-zone {
|
||||
.tree-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chevron-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user