feat: enhance notes list UI with improved visual hierarchy
- Added new folder creation endpoint with support for path or parent/name parameters - Updated notes list styling with consistent row cards and active state indicators - Improved theme-aware color variables for better light/dark mode contrast - Added visual depth with subtle gradient overlays and active item highlighting - Implemented consistent styling between virtual and standard note list views - Enhanced new note button styling for better visibility
This commit is contained in:
@@ -29,7 +29,7 @@ type CtxAction =
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [`
|
||||
:host { position: fixed; inset: 0; pointer-events: none; }
|
||||
:host { position: fixed; inset: 0; pointer-events: none; z-index: 9999; }
|
||||
.ctx {
|
||||
pointer-events: auto;
|
||||
min-width: 14rem;
|
||||
@@ -44,6 +44,7 @@ type CtxAction =
|
||||
background: var(--card, #ffffff);
|
||||
border: 1px solid var(--border, #e5e7eb);
|
||||
color: var(--fg, #111827);
|
||||
z-index: 10000;
|
||||
}
|
||||
.item {
|
||||
display: block; width: 100%;
|
||||
@@ -94,7 +95,7 @@ type CtxAction =
|
||||
template: `
|
||||
<ng-container *ngIf="visible">
|
||||
<!-- Backdrop pour capter les clics extérieurs -->
|
||||
<div class="fixed inset-0" (click)="close()" aria-hidden="true"></div>
|
||||
<div class="fixed inset-0" (click)="close()" aria-hidden="true" style="z-index: 9998;"></div>
|
||||
|
||||
<!-- Menu -->
|
||||
<div
|
||||
@@ -176,7 +177,10 @@ export class ContextMenuComponent implements OnChanges, OnDestroy {
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['visible'] && this.visible) {
|
||||
// Laisse le DOM peindre le menu, puis calcule les bornes
|
||||
// Immediately set to click position to avoid flashing at 0,0
|
||||
this.left = this.x;
|
||||
this.top = this.y;
|
||||
// Then reposition for anti-overflow
|
||||
queueMicrotask(() => this.reposition());
|
||||
}
|
||||
if ((changes['x'] || changes['y']) && this.visible) {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Component, ChangeDetectionStrategy, input, output, inject, signal } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy, input, output, inject, signal, effect, computed } 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 { NotesListFocusService } from '../../app/services/notes-list-focus.service';
|
||||
import { FolderFilterService } from '../../app/services/folder-filter.service';
|
||||
import { BadgeCountComponent } from '../../app/shared/ui/badge-count.component';
|
||||
import { ContextMenuComponent } from '../context-menu/context-menu.component';
|
||||
|
||||
@@ -11,7 +13,7 @@ import { ContextMenuComponent } from '../context-menu/context-menu.component';
|
||||
selector: 'app-file-explorer',
|
||||
template: `
|
||||
<ul class="space-y-0.5">
|
||||
@for(node of nodes(); track node.path) {
|
||||
@for(node of filteredNodes(); track node.path) {
|
||||
<li>
|
||||
@if (isFolder(node) && node.name !== '.trash') {
|
||||
@let folder = node;
|
||||
@@ -20,13 +22,14 @@ import { ContextMenuComponent } from '../context-menu/context-menu.component';
|
||||
(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"
|
||||
[ngStyle]="getFolderGradientStyle(folder.path)"
|
||||
>
|
||||
<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" 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>
|
||||
<app-badge-count class="ml-auto" [count]="folderCount(folder.path)" [color]="getFolderBadgeColor(folder.path)"></app-badge-count>
|
||||
</div>
|
||||
@if (folder.isOpen) {
|
||||
<div class="pl-5">
|
||||
@@ -107,6 +110,43 @@ import { ContextMenuComponent } from '../context-menu/context-menu.component';
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Subfolder Modal -->
|
||||
<div *ngIf="createModalVisible()" 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">Create a new folder</h3>
|
||||
|
||||
<div class="mb-1">
|
||||
<label class="block text-sm font-medium text-[var(--text-main)] mb-2">
|
||||
Folder name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="createInputValue"
|
||||
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="Folder name"
|
||||
(keydown.enter)="confirmCreate()"
|
||||
id="createInput" #createInput
|
||||
/>
|
||||
</div>
|
||||
<div *ngIf="createError()" class="text-sm text-red-500 mt-1">{{ createError() }}</div>
|
||||
|
||||
<div class="flex justify-end gap-3 mt-4">
|
||||
<button
|
||||
(click)="cancelCreate()"
|
||||
class="px-4 py-2 text-[var(--text-main)] hover:bg-[var(--surface-1)] rounded-md transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
(click)="confirmCreate()"
|
||||
class="px-4 py-2 bg-[var(--primary)] text-white rounded-md hover:bg-[var(--brand-700)] transition-colors"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, FormsModule, BadgeCountComponent, ContextMenuComponent],
|
||||
@@ -131,11 +171,51 @@ export class FileExplorerComponent {
|
||||
renameInputValue = '';
|
||||
renameTarget: VaultFolder | null = null;
|
||||
|
||||
// Create subfolder modal state
|
||||
createModalVisible = signal(false);
|
||||
createInputValue = '';
|
||||
createParentPath: string | null = null;
|
||||
createError = signal('');
|
||||
|
||||
// Folder colors storage
|
||||
private folderColors = new Map<string, string>();
|
||||
|
||||
private vaultService = inject(VaultService);
|
||||
private noteCreation = inject(NoteCreationService);
|
||||
private notesListFocus = inject(NotesListFocusService);
|
||||
private folderFilter = inject(FolderFilterService);
|
||||
|
||||
// Computed filtered nodes based on folder filter settings
|
||||
filteredNodes = computed(() => {
|
||||
const allNodes = this.nodes();
|
||||
return this.filterNodes(allNodes);
|
||||
});
|
||||
|
||||
/**
|
||||
* Recursively filter nodes based on folder filter settings
|
||||
*/
|
||||
private filterNodes(nodes: VaultNode[]): VaultNode[] {
|
||||
return nodes.filter(node => {
|
||||
if (node.type === 'folder') {
|
||||
// Check if folder should be filtered out
|
||||
if (this.folderFilter.shouldFilterFolder(node.path)) {
|
||||
return false;
|
||||
}
|
||||
// Recursively filter children
|
||||
const folder = node as VaultFolder;
|
||||
if (folder.children && folder.children.length > 0) {
|
||||
folder.children = this.filterNodes(folder.children);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}).map(node => {
|
||||
if (node.type === 'folder') {
|
||||
const folder = node as VaultFolder;
|
||||
return { ...folder };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
||||
folderCount(path: string): number {
|
||||
const quickLink = this.quickLinkFilter();
|
||||
@@ -169,6 +249,42 @@ export class FileExplorerComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the color for a folder badge, synchronized with folder color
|
||||
*/
|
||||
getFolderBadgeColor(folderPath: string): string {
|
||||
const folderColor = this.getFolderColor(folderPath);
|
||||
// If folder has a custom color, use it for the badge
|
||||
if (folderColor && folderColor !== 'currentColor') {
|
||||
return folderColor;
|
||||
}
|
||||
// Default badge color
|
||||
return 'slate';
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a subtle right-to-center horizontal gradient for folder rows
|
||||
* using the custom folder color. Returns an inline style object or null.
|
||||
*/
|
||||
getFolderGradientStyle(folderPath: string): Record<string, string> | null {
|
||||
const color = this.getFolderColor(folderPath);
|
||||
if (!color || color === 'currentColor') return null;
|
||||
// Use color with transparency; fall back directly if color is not hex
|
||||
// For hex like #RRGGBB convert to rgba with ~12% -> 0% fade
|
||||
const hexMatch = /^#([0-9a-fA-F]{6})$/.exec(color);
|
||||
let gradientColor = color;
|
||||
if (hexMatch) {
|
||||
const hex = hexMatch[1];
|
||||
const r = parseInt(hex.slice(0,2), 16);
|
||||
const g = parseInt(hex.slice(2,4), 16);
|
||||
const b = parseInt(hex.slice(4,6), 16);
|
||||
gradientColor = `rgba(${r}, ${g}, ${b}, 0.14)`; // ~14% alpha
|
||||
}
|
||||
return {
|
||||
backgroundImage: `linear-gradient(to left, ${gradientColor} 0%, transparent 65%)`
|
||||
} as Record<string, string>;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// FOLDER COLOR MANAGEMENT
|
||||
// ========================================
|
||||
@@ -269,7 +385,7 @@ export class FileExplorerComponent {
|
||||
|
||||
switch (action) {
|
||||
case 'create-subfolder':
|
||||
this.createSubfolder();
|
||||
this.openCreateModal(this.ctxTarget?.path || null);
|
||||
break;
|
||||
case 'rename':
|
||||
this.openRenameModal();
|
||||
@@ -295,17 +411,58 @@ export class FileExplorerComponent {
|
||||
onContextMenuColor(color: string) {
|
||||
if (!this.ctxTarget) return;
|
||||
this.setFolderColor(this.ctxTarget.path, color);
|
||||
this.showNotification(`Folder color updated to ${color}`, 'success');
|
||||
// Close context menu after color selection
|
||||
this.ctxVisible.set(false);
|
||||
this.showNotification(`Folder color updated`, '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 openCreateModal(parentPath: string | null) {
|
||||
this.createParentPath = parentPath ?? null;
|
||||
this.createInputValue = '';
|
||||
this.createError.set('');
|
||||
this.createModalVisible.set(true);
|
||||
setTimeout(() => {
|
||||
try { (document.querySelector('#createInput') as HTMLInputElement)?.focus(); } catch {}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private async confirmCreate() {
|
||||
const name = (this.createInputValue || '').trim();
|
||||
if (!name) { this.createError.set('Please enter a folder name'); return; }
|
||||
|
||||
const payload = this.createParentPath != null
|
||||
? { parentPath: this.createParentPath, newFolderName: name }
|
||||
: { path: name };
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/folders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to create folder');
|
||||
|
||||
this.showNotification(`Folder "${name}" created successfully`, 'success');
|
||||
this.cancelCreate();
|
||||
this.ctxVisible.set(false);
|
||||
this.vaultService.refreshFoldersTree(true);
|
||||
} catch (err: any) {
|
||||
console.error('Create folder error:', err);
|
||||
this.createError.set(err?.message || 'Failed to create folder');
|
||||
}
|
||||
}
|
||||
|
||||
private cancelCreate() {
|
||||
this.createModalVisible.set(false);
|
||||
this.createInputValue = '';
|
||||
this.createParentPath = null;
|
||||
this.createError.set('');
|
||||
}
|
||||
|
||||
openCreateAtRoot() {
|
||||
this.openCreateModal(null);
|
||||
}
|
||||
|
||||
private openRenameModal() {
|
||||
@@ -438,6 +595,9 @@ export class FileExplorerComponent {
|
||||
this.removeFolderColorsRecursively(target.path);
|
||||
this.persistFolderColors();
|
||||
|
||||
// Reset focus in Notes-list when folder is deleted
|
||||
this.notesListFocus.resetFocus();
|
||||
|
||||
this.showNotification(`Folder deleted: ${target.name}`, 'success');
|
||||
this.ctxVisible.set(false);
|
||||
|
||||
@@ -478,6 +638,28 @@ export class FileExplorerComponent {
|
||||
|
||||
constructor() {
|
||||
this.loadFolderColors();
|
||||
|
||||
// Close context menu when clicking outside
|
||||
effect(() => {
|
||||
if (this.ctxVisible()) {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
// Check if click is outside the context menu
|
||||
if (!target.closest('app-context-menu') && !target.closest('[role="menu"]')) {
|
||||
this.ctxVisible.set(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Add listener on next tick to avoid immediate closure
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', handleClickOutside, true);
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClickOutside, true);
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
|
||||
Reference in New Issue
Block a user