feat: add folder management endpoints and CORS support

This commit is contained in:
2025-10-24 08:02:40 -04:00
parent 7e20098d14
commit 08a2d05dad
39 changed files with 5267 additions and 132 deletions
@@ -0,0 +1,233 @@
import {
Component,
ChangeDetectionStrategy,
ElementRef,
EventEmitter,
HostListener,
Input,
OnChanges,
OnDestroy,
Output,
Renderer2,
SimpleChanges,
ViewChild,
} from '@angular/core';
import { CommonModule } from '@angular/common';
type CtxAction =
| 'create-subfolder'
| 'rename'
| 'duplicate'
| 'create-page'
| 'copy-link'
| 'delete-folder'
| 'delete-all';
@Component({
selector: 'app-context-menu',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [`
:host { position: fixed; inset: 0; pointer-events: none; }
.ctx {
pointer-events: auto;
min-width: 14rem;
border-radius: 0.75rem;
padding: 0.25rem 0.25rem;
box-shadow: 0 10px 30px rgba(0,0,0,.25);
backdrop-filter: blur(6px);
animation: fadeIn .12s ease-out;
transform-origin: top left;
user-select: none;
/* Theme-aware background and border */
background: var(--card, #ffffff);
border: 1px solid var(--border, #e5e7eb);
color: var(--fg, #111827);
}
.item {
display: block; width: 100%;
text-align: left;
padding: .5rem .75rem;
border-radius: .5rem;
border: none;
background: transparent;
cursor: pointer;
font-size: 0.875rem;
transition: background-color 0.08s ease;
color: var(--text-main, #111827);
}
.item:hover {
background: color-mix(in oklab, var(--surface-1, #f8fafc) 90%, black 0%);
}
.item:active {
background: color-mix(in oklab, var(--surface-2, #eef2f7) 85%, black 0%);
}
.item.danger { color: var(--danger, #ef4444); }
.item.warning { color: var(--warning, #f59e0b); }
.sep {
border-top: 1px solid var(--border, #e5e7eb);
margin:.25rem 0;
}
.row {
display:flex;
align-items:center;
justify-content:space-around;
gap:.5rem;
padding:.5rem .5rem;
}
.dot {
width: 1rem;
height: 1rem;
border-radius: 9999px;
cursor: pointer;
transition: transform .08s ease;
border: 2px solid transparent;
}
.dot:hover {
transform: scale(1.15);
outline: 2px solid color-mix(in oklab, var(--canvas, #ffffff) 70%, var(--fg, #111827) 15%);
outline-offset: 2px;
}
@keyframes fadeIn { from { opacity:0; transform: scale(.95);} to { opacity:1; transform: scale(1);} }
`],
template: `
<ng-container *ngIf="visible">
<!-- Backdrop pour capter les clics extérieurs -->
<div class="fixed inset-0" (click)="close()" aria-hidden="true"></div>
<!-- Menu -->
<div
#menu
class="ctx"
[ngStyle]="{ left: left + 'px', top: top + 'px', position:'fixed' }"
role="menu"
(contextmenu)="$event.preventDefault()"
>
<button class="item" (click)="emitAction('create-subfolder')">
<span class="inline-block mr-2">📁</span>Create subfolder
</button>
<button class="item" (click)="emitAction('rename')">
<span class="inline-block mr-2">✏️</span>Rename
</button>
<button class="item" (click)="emitAction('duplicate')">
<span class="inline-block mr-2">📋</span>Duplicate
</button>
<button class="item" (click)="emitAction('create-page')">
<span class="inline-block mr-2">📄</span>Create new page
</button>
<button class="item" (click)="emitAction('copy-link')">
<span class="inline-block mr-2">🔗</span>Copy internal link
</button>
<div class="sep"></div>
<button class="item danger" (click)="emitAction('delete-folder')">
<span class="inline-block mr-2">🗑️</span>Delete folder
</button>
<button class="item warning" (click)="emitAction('delete-all')">
<span class="inline-block mr-2">⚠️</span>Delete all pages in folder
</button>
<div class="sep"></div>
<div class="row">
<div *ngFor="let c of colors"
class="dot"
[style.background]="c"
(click)="emitColor(c)"
[attr.aria-label]="'color ' + c"
role="button"
title="Set folder color"></div>
</div>
</div>
</ng-container>
`,
})
export class ContextMenuComponent implements OnChanges, OnDestroy {
/** Position demandée (pixels viewport) */
@Input() x = 0;
@Input() y = 0;
/** Contrôle d'affichage */
@Input() visible = false;
/** Actions/retours */
@Output() action = new EventEmitter<CtxAction>();
@Output() color = new EventEmitter<string>();
@Output() closed = new EventEmitter<void>();
/** Palette 8 couleurs */
colors = ['#0ea5e9','#3b82f6','#22c55e','#eab308','#f97316','#ef4444','#a855f7','#64748b'];
/** Position corrigée (anti overflow) */
left = 0;
top = 0;
@ViewChild('menu') menuRef?: ElementRef<HTMLElement>;
private removeResize?: () => void;
private removeScroll?: () => void;
constructor(private r2: Renderer2, private host: ElementRef<HTMLElement>) {
// listeners globaux qui ferment le menu
this.removeResize = this.r2.listen('window', 'resize', () => this.reposition());
this.removeScroll = this.r2.listen('window', 'scroll', () => this.reposition());
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['visible'] && this.visible) {
// Laisse le DOM peindre le menu, puis calcule les bornes
queueMicrotask(() => this.reposition());
}
if ((changes['x'] || changes['y']) && this.visible) {
queueMicrotask(() => this.reposition());
}
}
ngOnDestroy(): void {
this.removeResize?.();
this.removeScroll?.();
}
/** Ferme le menu */
close() {
if (!this.visible) return;
this.visible = false;
this.closed.emit();
}
emitAction(a: CtxAction) {
this.action.emit(a);
this.close();
}
emitColor(c: string) {
this.color.emit(c);
this.close();
}
/** Corrige la position si le menu sortirait du viewport */
private reposition() {
const el = this.menuRef?.nativeElement;
if (!el) { this.left = this.x; this.top = this.y; return; }
const menuRect = el.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let left = this.x;
let top = this.y;
if (left + menuRect.width > vw - 8) left = Math.max(8, vw - menuRect.width - 8);
if (top + menuRect.height > vh - 8) top = Math.max(8, vh - menuRect.height - 8);
this.left = left;
this.top = top;
}
/** Fermer avec ESC */
@HostListener('window:keydown', ['$event'])
onKey(e: KeyboardEvent) {
if (e.key === 'Escape') this.close();
}
}
@@ -0,0 +1,179 @@
/**
* Context Menu Configuration
* Defines all available actions and color palette for folder context menu
*/
export type ContextMenuAction =
| 'create-subfolder'
| 'rename'
| 'duplicate'
| 'create-page'
| 'copy-link'
| 'delete-folder'
| 'delete-all';
export interface ContextMenuActionConfig {
id: ContextMenuAction;
label: string;
icon: string;
description: string;
isDangerous: boolean;
requiresConfirmation: boolean;
}
export interface ContextMenuColorConfig {
hex: string;
name: string;
description: string;
}
/**
* Available actions in the context menu
*/
export const CONTEXT_MENU_ACTIONS: ContextMenuActionConfig[] = [
{
id: 'create-subfolder',
label: 'Create subfolder',
icon: '📁',
description: 'Create a new subfolder inside this folder',
isDangerous: false,
requiresConfirmation: false,
},
{
id: 'rename',
label: 'Rename',
icon: '✏️',
description: 'Rename this folder',
isDangerous: false,
requiresConfirmation: false,
},
{
id: 'duplicate',
label: 'Duplicate',
icon: '📋',
description: 'Create a copy of this folder with all its contents',
isDangerous: false,
requiresConfirmation: false,
},
{
id: 'create-page',
label: 'Create new page',
icon: '📄',
description: 'Create a new markdown page in this folder',
isDangerous: false,
requiresConfirmation: false,
},
{
id: 'copy-link',
label: 'Copy internal link',
icon: '🔗',
description: 'Copy the internal link to this folder',
isDangerous: false,
requiresConfirmation: false,
},
{
id: 'delete-folder',
label: 'Delete folder',
icon: '🗑️',
description: 'Delete this folder',
isDangerous: true,
requiresConfirmation: true,
},
{
id: 'delete-all',
label: 'Delete all pages in folder',
icon: '⚠️',
description: 'Delete all pages in this folder (cannot be undone)',
isDangerous: true,
requiresConfirmation: true,
},
];
/**
* Color palette for folder categorization
*/
export const CONTEXT_MENU_COLORS: ContextMenuColorConfig[] = [
{
hex: '#0ea5e9',
name: 'Sky Blue',
description: 'Default, general purpose',
},
{
hex: '#3b82f6',
name: 'Blue',
description: 'Important folders',
},
{
hex: '#22c55e',
name: 'Green',
description: 'Active projects',
},
{
hex: '#eab308',
name: 'Yellow',
description: 'Attention needed',
},
{
hex: '#f97316',
name: 'Orange',
description: 'In progress',
},
{
hex: '#ef4444',
name: 'Red',
description: 'Critical/Urgent',
},
{
hex: '#a855f7',
name: 'Purple',
description: 'Archive/Special',
},
{
hex: '#64748b',
name: 'Gray',
description: 'Inactive/Old',
},
];
/**
* Get action configuration by ID
*/
export function getActionConfig(id: ContextMenuAction): ContextMenuActionConfig | undefined {
return CONTEXT_MENU_ACTIONS.find(action => action.id === id);
}
/**
* Get color configuration by hex value
*/
export function getColorConfig(hex: string): ContextMenuColorConfig | undefined {
return CONTEXT_MENU_COLORS.find(color => color.hex === hex);
}
/**
* Get all hex colors for the palette
*/
export function getAllColors(): string[] {
return CONTEXT_MENU_COLORS.map(color => color.hex);
}
/**
* Check if an action is dangerous (requires confirmation)
*/
export function isActionDangerous(id: ContextMenuAction): boolean {
const action = getActionConfig(id);
return action?.isDangerous ?? false;
}
/**
* Get confirmation message for an action
*/
export function getConfirmationMessage(id: ContextMenuAction, folderName: string): string {
switch (id) {
case 'delete-folder':
return `Are you sure you want to delete the folder "${folderName}"?`;
case 'delete-all':
return `Are you sure you want to delete ALL pages in "${folderName}"? This cannot be undone.`;
default:
return 'Are you sure?';
}
}
@@ -1,8 +1,11 @@
import { Component, ChangeDetectionStrategy, input, output, inject } from '@angular/core';
import { Component, ChangeDetectionStrategy, input, output, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { VaultNode, VaultFile, VaultFolder } from '../../types';
import { VaultService } from '../../services/vault.service';
import { NoteCreationService } from '../../app/services/note-creation.service';
import { BadgeCountComponent } from '../../app/shared/ui/badge-count.component';
import { ContextMenuComponent } from '../context-menu/context-menu.component';
@Component({
selector: 'app-file-explorer',
@@ -15,12 +18,13 @@ import { BadgeCountComponent } from '../../app/shared/ui/badge-count.component';
<div>
<div
(click)="onFolderClick(folder)"
(contextmenu)="openContextMenu($event, folder)"
class="flex items-center cursor-pointer px-3 py-2 rounded-lg my-0.5 text-sm hover:bg-slate-500/10 dark:hover:bg-surface2/10 transition"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 transition-transform text-obs-l-text-muted dark:text-obs-d-text-muted" [class.rotate-90]="folder.isOpen" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 transition-transform flex-shrink-0" [class.rotate-90]="folder.isOpen" fill="none" viewBox="0 0 24 24" stroke="currentColor" [style.color]="getFolderColor(folder.path)">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 flex-shrink-0 text-obs-l-text-muted dark:text-obs-d-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" [style.color]="getFolderColor(folder.path)"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path></svg>
<span class="font-semibold truncate">{{ folder.name }}</span>
<app-badge-count class="ml-auto" [count]="folderCount(folder.path)" color="slate"></app-badge-count>
</div>
@@ -57,9 +61,55 @@ import { BadgeCountComponent } from '../../app/shared/ui/badge-count.component';
</li>
}
</ul>
<!-- Context Menu -->
<app-context-menu
[x]="ctxX()"
[y]="ctxY()"
[visible]="ctxVisible()"
(action)="onContextMenuAction($event)"
(color)="onContextMenuColor($event)"
(closed)="ctxVisible.set(false)">
</app-context-menu>
<!-- Rename Modal -->
<div *ngIf="renameModalVisible()" class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
<div class="bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg p-6 w-full max-w-md mx-4">
<h3 class="text-lg font-semibold text-[var(--fg)] mb-4">Rename Folder</h3>
<div class="mb-4">
<label class="block text-sm font-medium text-[var(--text-main)] mb-2">
New folder name
</label>
<input
type="text"
[(ngModel)]="renameInputValue"
class="w-full px-3 py-2 border border-[var(--border)] rounded-md bg-[var(--bg)] text-[var(--fg)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
placeholder="Enter new folder name"
(keydown.enter)="confirmRename()"
#renameInput
/>
</div>
<div class="flex justify-end gap-3">
<button
(click)="cancelRename()"
class="px-4 py-2 text-[var(--text-main)] hover:bg-[var(--surface-1)] rounded-md transition-colors"
>
Cancel
</button>
<button
(click)="confirmRename()"
class="px-4 py-2 bg-[var(--primary)] text-white rounded-md hover:bg-[var(--brand-700)] transition-colors"
>
Rename
</button>
</div>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, BadgeCountComponent],
imports: [CommonModule, FormsModule, BadgeCountComponent, ContextMenuComponent],
})
export class FileExplorerComponent {
nodes = input.required<VaultNode[]>();
@@ -70,7 +120,22 @@ export class FileExplorerComponent {
fileSelected = output<string>();
folderSelected = output<string>();
// Context menu state
ctxVisible = signal(false);
ctxX = signal(0);
ctxY = signal(0);
ctxTarget: VaultFolder | null = null;
// Rename modal state
renameModalVisible = signal(false);
renameInputValue = '';
renameTarget: VaultFolder | null = null;
// Folder colors storage
private folderColors = new Map<string, string>();
private vaultService = inject(VaultService);
private noteCreation = inject(NoteCreationService);
folderCount(path: string): number {
const quickLink = this.quickLinkFilter();
@@ -104,6 +169,70 @@ export class FileExplorerComponent {
}
}
// ========================================
// FOLDER COLOR MANAGEMENT
// ========================================
getFolderColor(folderPath: string): string {
return this.folderColors.get(folderPath) || 'currentColor';
}
private setFolderColor(folderPath: string, color: string) {
this.folderColors.set(folderPath, color);
// Persist to localStorage
const colors = Object.fromEntries(this.folderColors);
localStorage.setItem('folderColors', JSON.stringify(colors));
}
private loadFolderColors() {
const stored = localStorage.getItem('folderColors');
if (stored) {
try {
const colors = JSON.parse(stored);
this.folderColors = new Map(Object.entries(colors));
} catch (e) {
console.error('Failed to load folder colors:', e);
}
}
}
private persistFolderColors() {
const colors = Object.fromEntries(this.folderColors);
localStorage.setItem('folderColors', JSON.stringify(colors));
}
private migrateFolderColors(oldBase: string, newBase: string) {
if (!oldBase || !newBase || oldBase === newBase) return;
const updates: Array<{ from: string; to: string; value: string }> = [];
const prefix = oldBase + '/';
for (const [key, value] of this.folderColors.entries()) {
if (key === oldBase) {
updates.push({ from: key, to: newBase, value });
} else if (key.startsWith(prefix)) {
const remainder = key.slice(prefix.length);
updates.push({ from: key, to: `${newBase}/${remainder}`, value });
}
}
for (const u of updates) {
this.folderColors.delete(u.from);
this.folderColors.set(u.to, u.value);
}
}
private removeFolderColorsRecursively(base: string) {
if (!base) return;
const toDelete: string[] = [];
const prefix = base + '/';
for (const key of this.folderColors.keys()) {
if (key === base || key.startsWith(prefix)) {
toDelete.push(key);
}
}
for (const k of toDelete) this.folderColors.delete(k);
}
onFileSelected(noteId: string) {
if(noteId) {
this.fileSelected.emit(noteId);
@@ -124,4 +253,234 @@ export class FileExplorerComponent {
isFolder(node: VaultNode): node is VaultFolder {
return node.type === 'folder';
}
// Context menu methods
openContextMenu(event: MouseEvent, folder: VaultFolder) {
event.preventDefault();
event.stopPropagation();
this.ctxTarget = folder;
this.ctxX.set(event.clientX);
this.ctxY.set(event.clientY);
this.ctxVisible.set(true);
}
onContextMenuAction(action: string) {
if (!this.ctxTarget) return;
switch (action) {
case 'create-subfolder':
this.createSubfolder();
break;
case 'rename':
this.openRenameModal();
break;
case 'duplicate':
this.duplicateFolder();
break;
case 'create-page':
this.createPageInFolder();
break;
case 'copy-link':
this.copyInternalLink();
break;
case 'delete-folder':
this.deleteFolder();
break;
case 'delete-all':
this.deleteAllPagesInFolder();
break;
}
}
onContextMenuColor(color: string) {
if (!this.ctxTarget) return;
this.setFolderColor(this.ctxTarget.path, color);
this.showNotification(`Folder color updated to ${color}`, 'success');
}
// Action implementations
private createSubfolder() {
if (!this.ctxTarget) return;
const name = prompt('Enter subfolder name:');
if (!name) return;
const newPath = `${this.ctxTarget.path}/${name}`;
// TODO: Implement actual folder creation via VaultService
this.showNotification(`Creating subfolder: ${newPath}`, 'info');
}
private openRenameModal() {
if (!this.ctxTarget) return;
this.renameTarget = this.ctxTarget;
this.renameInputValue = this.ctxTarget.name;
this.renameModalVisible.set(true);
}
private async confirmRename() {
if (!this.renameTarget || !this.renameInputValue.trim()) return;
const newName = this.renameInputValue.trim();
if (newName === this.renameTarget.name) {
this.cancelRename();
return;
}
try {
const response = await fetch('/api/folders/rename', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
oldPath: this.renameTarget.path,
newName: newName
})
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || 'Failed to rename folder');
}
// Compute new folder path (relative)
const oldPath = this.renameTarget.path;
const parent = oldPath.includes('/') ? oldPath.slice(0, oldPath.lastIndexOf('/')) : '';
const newPath = parent ? `${parent}/${newName}` : newName;
// Migrate colors for the folder and its subfolders
this.migrateFolderColors(oldPath, newPath);
this.persistFolderColors();
this.showNotification(`Folder renamed to "${newName}" successfully`, 'success');
this.cancelRename();
// Trigger an authoritative refresh to avoid any stale state until SSE arrives
this.vaultService.refreshFoldersTree(true);
} catch (error) {
console.error('Rename folder error:', error);
this.showNotification(`Failed to rename folder: ${error.message}`, 'error');
}
}
private cancelRename() {
this.renameModalVisible.set(false);
this.renameInputValue = '';
this.renameTarget = null;
}
private duplicateFolder() {
if (!this.ctxTarget) return;
const newName = prompt('Enter duplicate folder name:', `${this.ctxTarget.name} (copy)`);
if (!newName) return;
const src = this.ctxTarget.path;
const parent = src.includes('/') ? src.slice(0, src.lastIndexOf('/')) : '';
const dst = parent ? `${parent}/${newName}` : newName;
(async () => {
try {
const res = await fetch('/api/folders/duplicate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sourcePath: src, destinationPath: dst })
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Failed to duplicate folder');
this.showNotification(`Folder duplicated to: ${dst}`, 'success');
this.vaultService.refreshFoldersTree(true);
} catch (err) {
console.error('Duplicate folder error:', err);
this.showNotification(`Failed to duplicate folder: ${err.message}`, 'error');
}
})();
}
private createPageInFolder() {
if (!this.ctxTarget) return;
const pageName = prompt('Enter page name:');
if (!pageName) return;
(async () => {
try {
const resp = await this.noteCreation.createNote(pageName, this.ctxTarget!.path);
this.showNotification(`Page created: ${resp.fileName}`, 'success');
this.vaultService.refreshFoldersTree(true);
} catch (e:any) {
console.error('Create page error:', e);
this.showNotification(`Failed to create page: ${e.message || e}`, 'error');
}
})();
}
private copyInternalLink() {
if (!this.ctxTarget) return;
const link = `[[${this.ctxTarget.path}]]`;
navigator.clipboard.writeText(link).then(() => {
this.showNotification('Internal link copied to clipboard!', 'success');
}).catch(() => {
this.showNotification('Failed to copy link', 'error');
});
}
private deleteFolder() {
if (!this.ctxTarget) return;
const confirmed = confirm(`Are you sure you want to delete the folder "${this.ctxTarget.name}"?`);
if (!confirmed) return;
const target = this.ctxTarget;
(async () => {
try {
const res = await fetch('/api/folders?path=' + encodeURIComponent(target.path), {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' }
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Failed to delete folder');
// Remove any stored colors for this folder and its descendants
this.removeFolderColorsRecursively(target.path);
this.persistFolderColors();
this.showNotification(`Folder deleted: ${target.name}`, 'success');
this.ctxVisible.set(false);
// Trigger an authoritative refresh to avoid any stale state until SSE arrives
this.vaultService.refreshFoldersTree(true);
} catch (err) {
console.error('Delete folder error:', err);
this.showNotification(`Failed to delete folder: ${err.message}`, 'error');
}
})();
}
private deleteAllPagesInFolder() {
if (!this.ctxTarget) return;
const confirmed = confirm(`Are you sure you want to delete ALL pages in "${this.ctxTarget.name}"? This cannot be undone.`);
if (!confirmed) return;
const target = this.ctxTarget.path;
(async () => {
try {
const res = await fetch('/api/folders/pages?path=' + encodeURIComponent(target), { method: 'DELETE' });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Failed to delete pages');
this.showNotification(`Deleted ${data.deletedCount ?? 0} pages in folder: ${this.ctxTarget!.name}`, 'success');
this.vaultService.refreshFoldersTree(true);
} catch (err:any) {
console.error('Delete all pages error:', err);
this.showNotification(`Failed to delete pages: ${err.message || err}`, 'error');
}
})();
}
private showNotification(message: string, type: 'success' | 'info' | 'warning' | 'error') {
// Simple notification using browser's native capabilities
console.log(`[${type.toUpperCase()}] ${message}`);
// You can replace this with a proper toast notification service if available
}
constructor() {
this.loadFolderColors();
}
ngOnInit() {
// Initialize component
}
}