feat: unify filter badges and enhance search UX

- Introduced FilterBadgeComponent to consolidate tag, folder, quicklink and kind filters into a single row of badges
- Added FilterService to manage cumulative tag filtering and kind selection state
- Enhanced search behavior to maintain focus while typing and trigger note selection on enter
- Reorganized sidebar sections to close other sections when one is opened
- Added clear methods for individual filter types in UrlStateService
- Improved folder
This commit is contained in:
Bruno Charest 2025-10-30 14:01:26 -04:00
parent 6f01d65411
commit 58b22a47c9
7 changed files with 349 additions and 117 deletions

View File

@ -0,0 +1,41 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-filter-badge',
standalone: true,
imports: [CommonModule],
template: `
<span class="badge inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs select-none">
<span class="icon" aria-hidden="true">{{ icon }}</span>
<span class="label truncate">{{ label }}</span>
<button type="button" class="remove inline-flex items-center justify-center w-5 h-5 rounded-full" (click)="remove.emit()" [attr.aria-label]="'Remove ' + label">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
</span>
`,
styles: [`
:host { display: inline-flex; }
.badge {
background: var(--badge-bg, color-mix(in oklab, var(--card) 96%, black 4%));
color: var(--badge-fg, var(--text-main));
border: 1px solid color-mix(in oklab, var(--border) 70%, transparent 30%);
box-shadow: 0 1px 1.5px color-mix(in oklab, var(--shadow) 8%, transparent 92%);
}
:host-context(html.dark) .badge {
background: var(--badge-bg, color-mix(in oklab, var(--card) 86%, black 14%));
color: var(--badge-fg, var(--text-main));
border-color: color-mix(in oklab, var(--border) 55%, transparent 45%);
}
.remove { color: var(--text-muted); }
.remove:hover { background: color-mix(in oklab, var(--surface2) 25%, transparent 75%); color: var(--text-main); }
`]
})
export class FilterBadgeComponent {
@Input() label = '';
@Input() icon = '';
@Output() remove = new EventEmitter<void>();
}

View File

@ -8,6 +8,8 @@ import { NotesListStateService, SortBy, ViewMode } from '../../services/notes-li
import { NoteCreationService } from '../../services/note-creation.service'; import { NoteCreationService } from '../../services/note-creation.service';
import { NoteContextMenuComponent } from '../../../components/note-context-menu/note-context-menu.component'; import { NoteContextMenuComponent } from '../../../components/note-context-menu/note-context-menu.component';
import { WarningPanelComponent } from '../../components/warning-panel/warning-panel.component'; import { WarningPanelComponent } from '../../components/warning-panel/warning-panel.component';
import { FilterBadgeComponent } from '../../components/filter-badge/filter-badge.component';
import { FilterService } from '../../services/filter.service';
import { NoteContextMenuService } from '../../services/note-context-menu.service'; import { NoteContextMenuService } from '../../services/note-context-menu.service';
import { UrlStateService } from '../../services/url-state.service'; import { UrlStateService } from '../../services/url-state.service';
import { EditorStateService } from '../../../services/editor-state.service'; import { EditorStateService } from '../../../services/editor-state.service';
@ -17,28 +19,16 @@ import { FileTypeDetectorService } from '../../../services/file-type-detector.se
@Component({ @Component({
selector: 'app-notes-list', selector: 'app-notes-list',
standalone: true, standalone: true,
imports: [CommonModule, ScrollableOverlayDirective, NoteContextMenuComponent, WarningPanelComponent], imports: [CommonModule, ScrollableOverlayDirective, NoteContextMenuComponent, WarningPanelComponent, FilterBadgeComponent],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<div class="h-full flex flex-col"> <div class="h-full flex flex-col">
<!-- Header with filters --> <!-- Header with filters -->
<div class="p-2 border-b border-border dark:border-gray-800 space-y-2"> <div class="p-2 border-b border-border dark:border-gray-800 space-y-2">
<div *ngIf="activeTag() as t" class="flex items-center gap-2 text-xs"> <!-- Unified badges row (tag, folder, quick, kinds) -->
<span class="inline-flex items-center gap-1 rounded-full bg-surface1 dark:bg-card text-main dark:text-main px-2 py-1"> <div class="flex flex-wrap items-center gap-1.5 min-h-[1.75rem]">
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 7h.01"/><path d="M3 7h5a2 2 0 0 1 1.414.586l7 7a2 2 0 0 1 0 2.828l-3.172 3.172a2 2 0 0 1-2.828 0l-7-7A2 2 0 0 1 3 12V7Z"/></svg> <app-filter-badge *ngFor="let b of filter.badges()"
Filtre: #{{ t }} [label]="b.label" [icon]="b.icon" (remove)="filter.removeBadge(b)"></app-filter-badge>
</span>
<button type="button" (click)="clearTagFilter()" class="rounded-full hover:bg-slate-500/10 dark:hover:bg-surface2/10 w-6 h-6 inline-flex items-center justify-center" title="Effacer le filtre">
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</div>
<div *ngIf="quickLinkFilter() && getQuickLinkDisplay(quickLinkFilter()) as ql" class="flex items-center gap-2 text-xs">
<span class="inline-flex items-center gap-1 rounded-full bg-surface1 dark:bg-card text-main dark:text-main px-2 py-1">
{{ ql.icon }} {{ ql.name }}
</span>
<button type="button" (click)="clearQuickLinkFilter.emit()" class="rounded-full hover:bg-slate-500/10 dark:hover:bg-surface2/10 w-6 h-6 inline-flex items-center justify-center" title="Effacer le filtre">
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</div> </div>
<!-- Path Indicator with Sort and View Mode Menus --> <!-- Path Indicator with Sort and View Mode Menus -->
@ -51,9 +41,10 @@ import { FileTypeDetectorService } from '../../../services/file-type-detector.se
<!-- Search and New Note --> <!-- Search and New Note -->
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<input type="text" <input #searchInput type="text"
[value]="query()" [value]="query()"
(input)="onQuery($any($event.target).value)" (input)="onQuery($any($event.target).value)"
(keydown.enter)="onSearchEnter()"
placeholder="Rechercher..." placeholder="Rechercher..."
class="flex-1 rounded border border-border dark:border-border bg-card dark:bg-main px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" /> class="flex-1 rounded border border-border dark:border-border bg-card dark:bg-main px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
<button type="button" <button type="button"
@ -475,6 +466,14 @@ import { FileTypeDetectorService } from '../../../services/file-type-detector.se
`] `]
}) })
export class NotesListComponent { export class NotesListComponent {
@ViewChild('listContainer') listContainer?: ElementRef<HTMLElement>;
@ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>;
private urlState = inject(UrlStateService);
private pendingSelectId = signal<string | null>(null);
private editorState = inject(EditorStateService);
private vault = inject(VaultService);
private fileTypes = inject(FileTypeDetectorService);
filter = inject(FilterService);
notes = input<Note[]>([]); notes = input<Note[]>([]);
folderFilter = input<string | null>(null); folderFilter = input<string | null>(null);
query = input<string>(''); query = input<string>('');
@ -489,73 +488,48 @@ export class NotesListComponent {
@Output() noteCreated = new EventEmitter<string>(); @Output() noteCreated = new EventEmitter<string>();
@Output() noteCreatedAndSelected = new EventEmitter<{ id: string; filePath: string }>(); @Output() noteCreatedAndSelected = new EventEmitter<{ id: string; filePath: string }>();
// Stores and services
private store = inject(TagFilterStore); private store = inject(TagFilterStore);
readonly state = inject(NotesListStateService); readonly state = inject(NotesListStateService);
private noteCreationService = inject(NoteCreationService); private noteCreationService = inject(NoteCreationService);
readonly contextMenuService = inject(NoteContextMenuService); readonly contextMenuService = inject(NoteContextMenuService);
@ViewChild('listContainer') listContainer?: ElementRef<HTMLElement>;
private urlState = inject(UrlStateService); // Local state
private pendingSelectId = signal<string | null>(null); private q = signal('');
private editorState = inject(EditorStateService); activeTag = signal<string | null>(null);
private vault = inject(VaultService); sortMenuOpen = signal<boolean>(false);
private fileTypes = inject(FileTypeDetectorService); viewModeMenuOpen = signal<boolean>(false);
readonly sortOptions: SortBy[] = ['title', 'created', 'updated'];
readonly viewModes: ViewMode[] = ['compact', 'comfortable', 'detailed'];
// Delete warning modal state // Delete warning modal state
deleteWarningOpen = signal<boolean>(false); deleteWarningOpen = signal<boolean>(false);
private deleteTarget: Note | null = null; private deleteTarget: Note | null = null;
openDeleteWarning(note: Note) { openDeleteWarning(note: Note) {
console.log('[NotesList] Opening delete warning for note:', note.title);
// Close context menu so it does not overlay/capture clicks above the modal
this.contextMenuService.close(); this.contextMenuService.close();
this.deleteTarget = note; this.deleteTarget = note;
this.deleteWarningOpen.set(true); this.deleteWarningOpen.set(true);
} }
closeDeleteWarning() { closeDeleteWarning() {
console.log('[NotesList] Closing delete warning');
this.deleteWarningOpen.set(false); this.deleteWarningOpen.set(false);
this.deleteTarget = null; this.deleteTarget = null;
} }
async confirmDelete() { async confirmDelete() {
console.log('[NotesList] Confirm delete called for:', this.deleteTarget?.title);
const note = this.deleteTarget; const note = this.deleteTarget;
if (!note) { if (!note) { this.closeDeleteWarning(); return; }
console.warn('[NotesList] No delete target found');
this.closeDeleteWarning();
return;
}
try { try {
console.log('[NotesList] Calling deleteNoteConfirmed...');
await this.contextMenuService.deleteNoteConfirmed(note); await this.contextMenuService.deleteNoteConfirmed(note);
// Only close on success
console.log('[NotesList] Delete successful, closing modal');
this.closeDeleteWarning(); this.closeDeleteWarning();
this.contextMenuService.close(); this.contextMenuService.close();
} catch (error) { } catch (e) {
console.error('Confirm delete error:', error); console.error('Confirm delete error:', e);
// Keep modal open on error so user can try again or cancel
} }
} }
private q = signal(''); // Helpers from original component
activeTag = signal<string | null>(null);
sortMenuOpen = signal<boolean>(false);
viewModeMenuOpen = signal<boolean>(false);
readonly sortOptions: SortBy[] = ['title', 'created', 'updated'];
readonly viewModes: ViewMode[] = ['compact', 'comfortable', 'detailed'];
private syncQuery = effect(() => {
this.q.set(this.query() || '');
const startTime = performance.now();
setTimeout(() => {
const duration = Math.round(performance.now() - startTime);
this.state.setRequestStats(true, duration);
}, 10);
});
private buildUnifiedList(): Note[] { private buildUnifiedList(): Note[] {
const notes = this.notes(); const notes = this.notes();
const notePaths = new Set(notes.map(n => (n.filePath || '').toLowerCase().replace(/\\/g, '/'))); const notePaths = new Set(notes.map(n => (n.filePath || '').toLowerCase().replace(/\\/g, '/')));
@ -631,6 +605,8 @@ export class NotesListComponent {
} }
private scrollToSelectedEffect = effect(() => { private scrollToSelectedEffect = effect(() => {
// Do not steal focus from the search field while the user is typing
if ((this.q() || '').length > 0) return;
const id = this.selectedId() || this.pendingSelectId(); const id = this.selectedId() || this.pendingSelectId();
if (!id) return; if (!id) return;
const host = this.listContainer?.nativeElement; const host = this.listContainer?.nativeElement;
@ -646,12 +622,8 @@ export class NotesListComponent {
return false; return false;
}; };
// Attempt after microtask, then a few RAF retries to wait for DOM
queueMicrotask(() => { queueMicrotask(() => {
if (tryFocus()) { if (tryFocus()) return;
// If parent hasn't yet reflected selection, keep local pending highlight
return;
}
let attempts = 4; let attempts = 4;
const raf = () => { const raf = () => {
if (tryFocus()) return; if (tryFocus()) return;
@ -669,19 +641,46 @@ export class NotesListComponent {
} }
}); });
private syncTagFromStore = effect(() => { private keepSearchFocusEffect = effect(() => {
const inputTag = this.tagFilter(); const input = this.searchInput?.nativeElement;
if (inputTag !== null && inputTag !== undefined) { if (!input) return;
this.activeTag.set(inputTag || null); if (!(this.q() || '')) return; // only enforce while searching
return; queueMicrotask(() => {
try {
if (document.activeElement !== input) {
input.focus();
const len = input.value.length;
input.setSelectionRange(len, len);
} }
this.activeTag.set(this.store.get()); } catch {}
}); });
});
onQuery(v: string) {
this.q.set(v ?? '');
this.queryChange.emit(v ?? '');
}
private mapInternalQuickToFrontmatter(id: 'favoris' | 'publish' | 'draft' | 'template' | 'task' | 'private' | 'archive' | null): string | null {
switch (id || '') {
case 'favoris': return 'Favoris';
case 'publish': return 'Publié';
case 'draft': return 'Brouillons';
case 'template': return 'Modèles';
case 'task': return 'Tâches';
case 'private': return 'Privé';
case 'archive': return 'Archive';
default: return null;
}
}
filtered = computed(() => { filtered = computed(() => {
const q = (this.q() || '').toLowerCase().trim(); const q = (this.q() || '').toLowerCase().trim();
const folder = (this.folderFilter() || '').toLowerCase().replace(/^\/+|\/+$/g, ''); const folder = (this.folderFilter() || '').toLowerCase().replace(/^\/+|\/+$/g, '');
const tag = (this.activeTag() || '').toLowerCase(); // URL-provided single tag (from parent via input)
const urlTag = (this.tagFilter() || '').toLowerCase();
// Local cumulative tags from FilterService
const localTags = this.filter.tags().map(t => (t || '').toLowerCase());
const quickLink = this.quickLinkFilter(); const quickLink = this.quickLinkFilter();
const kind = this.kindFilter(); const kind = this.kindFilter();
const sortBy = this.state.sortBy(); const sortBy = this.state.sortBy();
@ -709,14 +708,21 @@ export class NotesListComponent {
} }
} }
if (tag) { // Tags: cumulative AND filter across URL tag + local tags
list = list.filter(n => Array.isArray(n.tags) && n.tags.some(t => (t || '').toLowerCase() === tag)); if (urlTag || localTags.length > 0) {
list = list.filter(n => {
const ntags = Array.isArray(n.tags) ? n.tags.map(t => (t || '').toLowerCase()) : [];
if (urlTag && !ntags.includes(urlTag)) return false;
for (const t of localTags) { if (!ntags.includes(t)) return false; }
return true;
});
} }
if (quickLink) { if (quickLink) {
const fmKey = this.mapInternalQuickToFrontmatter(quickLink);
list = list.filter(n => { list = list.filter(n => {
const frontmatter = n.frontmatter || {}; const frontmatter = n.frontmatter || {} as any;
return frontmatter[quickLink] === true; return fmKey ? frontmatter[fmKey] === true : false;
}); });
} }
@ -729,7 +735,10 @@ export class NotesListComponent {
} }
// Kind filter (file type) // Kind filter (file type)
if (kind && kind !== 'all') { const kinds = this.filter.kinds();
if (kinds.length > 0) {
list = list.filter(n => kinds.some(k => this.matchesKind(n, k as any)));
} else if (kind && kind !== 'all') {
list = list.filter(n => this.matchesKind(n, kind)); list = list.filter(n => this.matchesKind(n, kind));
} }
@ -748,28 +757,10 @@ export class NotesListComponent {
}); });
}); });
getQuickLinkDisplay(quickLink: string): { icon: string; name: string } | null { onSearchEnter(): void {
const displays: Record<string, { icon: string; name: string }> = { const first = this.filtered()[0];
'favoris': { icon: '❤️', name: 'Favoris' }, if (first) {
'publish': { icon: '🌐', name: 'Publish' }, this.openNote.emit(first.id);
'draft': { icon: '📝', name: 'Draft' },
'template': { icon: '📑', name: 'Template' },
'task': { icon: '🗒️', name: 'Task' },
'private': { icon: '🔒', name: 'Private' },
'archive': { icon: '🗃️', name: 'Archive' }
};
return displays[quickLink] || null;
}
onQuery(v: string) {
this.q.set(v);
this.queryChange.emit(v);
}
clearTagFilter(): void {
this.activeTag.set(null);
if (this.tagFilter() == null) {
this.store.set(null);
} }
} }

View File

@ -8,6 +8,8 @@ import type { VaultNode, TagInfo } from '../../../types';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { VaultService } from '../../../services/vault.service'; import { VaultService } from '../../../services/vault.service';
import { UrlStateService } from '../../services/url-state.service'; import { UrlStateService } from '../../services/url-state.service';
import { SidebarStateService } from '../../services/sidebar-state.service';
import { FilterService } from '../../services/filter.service';
@Component({ @Component({
selector: 'app-nimbus-sidebar', selector: 'app-nimbus-sidebar',
@ -56,7 +58,7 @@ import { UrlStateService } from '../../services/url-state.service';
<!-- Quick Links accordion --> <!-- Quick Links accordion -->
<section class="border-b border-border dark:border-gray-800"> <section class="border-b border-border dark:border-gray-800">
<button class="w-full flex items-center px-3 py-2 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card" <button class="w-full flex items-center px-3 py-2 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card"
(click)="open.quick = !open.quick"> (click)="toggleSection('quick')">
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<span class="text-xs text-muted">{{ open.quick ? '▾' : '▸' }}</span> <span class="text-xs text-muted">{{ open.quick ? '▾' : '▸' }}</span>
<span></span> <span></span>
@ -71,7 +73,7 @@ import { UrlStateService } from '../../services/url-state.service';
<!-- Folders accordion --> <!-- Folders accordion -->
<section class="border-b border-border dark:border-gray-800"> <section class="border-b border-border dark:border-gray-800">
<div class="w-full flex items-center px-3 py-2 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card"> <div class="w-full flex items-center px-3 py-2 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card">
<button class="flex items-center gap-2 flex-1 text-left" (click)="toggleFoldersSection()"> <button class="flex items-center gap-2 flex-1 text-left" (click)="toggleSection('folders')">
<span class="text-xs text-muted">{{ open.folders ? '▾' : '▸' }}</span> <span class="text-xs text-muted">{{ open.folders ? '▾' : '▸' }}</span>
<span>📁</span> <span>📁</span>
<span>Folders</span> <span>Folders</span>
@ -81,32 +83,32 @@ import { UrlStateService } from '../../services/url-state.service';
</button> </button>
<button *ngIf="open.folders" (click)="onCreateFolderAtRoot()" title="Create Folder" class="flex items-center gap-1 p-2 rounded hover:bg-surface1 dark:hover:bg-card"> <button *ngIf="open.folders" (click)="onCreateFolderAtRoot()" title="Create Folder" class="flex items-center gap-1 p-2 rounded hover:bg-surface1 dark:hover:bg-card">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 -ml-1" viewBox="0 0 20 20" fill="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 -ml-1" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 010 2h-3v3a1 1 0 01-2 0v-3H6a1 1 0 010-2h3V6a1 1 0 011-1z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 010 2h-3v3a1 1 0 01-2 0v-3H6a1 1 0 010-2h3V6a1 1 0 011-1z" clip-rule="evenodd" />
              </svg> </svg>
</button> </button>
</div> </div>
<div *ngIf="open.folders" class="px-1 py-1"> <div *ngIf="open.folders" class="px-1 py-1">
<div class="flex gap-2 flex-wrap px-2 pb-2"> <div class="flex gap-2 flex-wrap px-2 pb-2">
<button type="button" class="px-2 py-1 rounded text-xs" <button type="button" class="px-2 py-1 rounded text-xs"
[ngClass]="chipClass(urlState.isKindActive('image'))" [ngClass]="chipClass(filters.isKindActive('image'))"
(click)="setKind('image')" title="Images">🖼</button> (click)="setKind('image')" title="Images">🖼</button>
<button type="button" class="px-2 py-1 rounded text-xs" <button type="button" class="px-2 py-1 rounded text-xs"
[ngClass]="chipClass(urlState.isKindActive('video'))" [ngClass]="chipClass(filters.isKindActive('video'))"
(click)="setKind('video')" title="Vidéos">🎬</button> (click)="setKind('video')" title="Vidéos">🎬</button>
<button type="button" class="px-2 py-1 rounded text-xs" <button type="button" class="px-2 py-1 rounded text-xs"
[ngClass]="chipClass(urlState.isKindActive('pdf'))" [ngClass]="chipClass(filters.isKindActive('pdf'))"
(click)="setKind('pdf')" title="PDF">📄</button> (click)="setKind('pdf')" title="PDF">📄</button>
<button type="button" class="px-2 py-1 rounded text-xs" <button type="button" class="px-2 py-1 rounded text-xs"
[ngClass]="chipClass(urlState.isKindActive('markdown'))" [ngClass]="chipClass(filters.isKindActive('markdown'))"
(click)="setKind('markdown')" title="Markdown">📝</button> (click)="setKind('markdown')" title="Markdown">📝</button>
<button type="button" class="px-2 py-1 rounded text-xs" <button type="button" class="px-2 py-1 rounded text-xs"
[ngClass]="chipClass(urlState.isKindActive('excalidraw'))" [ngClass]="chipClass(filters.isKindActive('excalidraw'))"
(click)="setKind('excalidraw')" title="Excalidraw"></button> (click)="setKind('excalidraw')" title="Excalidraw"></button>
<button type="button" class="px-2 py-1 rounded text-xs font-mono" <button type="button" class="px-2 py-1 rounded text-xs font-mono"
[ngClass]="chipClass(urlState.isKindActive('code'))" [ngClass]="chipClass(filters.isKindActive('code'))"
(click)="setKind('code')" title="Code">&lt;/&gt;</button> (click)="setKind('code')" title="Code">&lt;/&gt;</button>
<button type="button" class="px-2 py-1 rounded text-xs" <button type="button" class="px-2 py-1 rounded text-xs"
[ngClass]="chipClass(urlState.isKindActive('all'))" [ngClass]="chipClass(filters.isKindActive('all'))"
(click)="setKind('all')" title="Tout"> Tout</button> (click)="setKind('all')" title="Tout"> Tout</button>
</div> </div>
<app-file-explorer <app-file-explorer
@ -124,7 +126,7 @@ import { UrlStateService } from '../../services/url-state.service';
<!-- Tags accordion --> <!-- Tags accordion -->
<section class="border-b border-border dark:border-gray-800"> <section class="border-b border-border dark:border-gray-800">
<button class="w-full flex items-center px-3 py-2 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card" <button class="w-full flex items-center px-3 py-2 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card"
(click)="open.tags = !open.tags"> (click)="toggleSection('tags')">
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<span class="text-xs text-muted">{{ open.tags ? '▾' : '▸' }}</span> <span class="text-xs text-muted">{{ open.tags ? '▾' : '▸' }}</span>
<span>🏷</span> <span>🏷</span>
@ -218,6 +220,8 @@ export class NimbusSidebarComponent implements OnChanges {
open = { quick: true, folders: true, tags: false, trash: false, tests: false }; open = { quick: true, folders: true, tags: false, trash: false, tests: false };
private vault = inject(VaultService); private vault = inject(VaultService);
urlState = inject(UrlStateService); urlState = inject(UrlStateService);
private sidebar = inject(SidebarStateService);
filters = inject(FilterService);
@ViewChild('foldersExplorer') private foldersExplorer?: FileExplorerComponent; @ViewChild('foldersExplorer') private foldersExplorer?: FileExplorerComponent;
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
@ -248,12 +252,11 @@ export class NimbusSidebarComponent implements OnChanges {
trashHasContent = () => (this.vault.trashTree() || []).length > 0; trashHasContent = () => (this.vault.trashTree() || []).length > 0;
trackNoteId = (_: number, n: { id: string }) => n.id; trackNoteId = (_: number, n: { id: string }) => n.id;
toggleFoldersSection(): void { toggleSection(which: 'quick' | 'folders' | 'tags'): void {
const next = !this.open.folders; // Open requested section, close others, and reset filters/search via SidebarStateService
this.open.folders = next; this.open = { quick: false, folders: false, tags: false, trash: false, tests: false };
if (next) { (this.open as any)[which] = true;
this.quickLinkSelected.emit('all'); this.sidebar.open(which);
}
} }
onCreateFolderAtRoot(): void { onCreateFolderAtRoot(): void {
@ -275,7 +278,7 @@ export class NimbusSidebarComponent implements OnChanges {
} }
setKind(kind: 'image'|'video'|'pdf'|'markdown'|'excalidraw'|'code'|'all') { setKind(kind: 'image'|'video'|'pdf'|'markdown'|'excalidraw'|'code'|'all') {
this.urlState.filterByKind(kind); this.filters.toggleKind(kind === 'all' ? 'all' : kind as any);
} }
chipClass(active: boolean): string { chipClass(active: boolean): string {

View File

@ -627,7 +627,10 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
onQueryChange(query: string) { onQueryChange(query: string) {
this.listQuery = query; this.listQuery = query;
// Only auto-select when query is cleared; while typing keep focus in search (handled by notes-list)
if (!query) {
this.autoSelectFirstNote(); this.autoSelectFirstNote();
}
// Sync URL search term // Sync URL search term
this.urlState.updateSearch(query); this.urlState.updateSearch(query);
} }

View File

@ -0,0 +1,153 @@
import { Injectable, computed, signal, inject, effect } from '@angular/core';
import { UrlStateService } from './url-state.service';
export type FileKind = 'image' | 'video' | 'pdf' | 'markdown' | 'excalidraw' | 'code';
export interface FilterBadgeItem {
id: string;
type: 'kind' | 'tag' | 'folder' | 'quick';
label: string;
icon: string;
}
@Injectable({ providedIn: 'root' })
export class FilterService {
private url = inject(UrlStateService);
// Multi-kind selection (not persisted to URL to keep backward compatibility)
private kindsSet = signal<Set<FileKind>>(new Set<FileKind>());
// Cumulative tags (local only; URL still holds at most one tag for deep links)
private tagsSet = signal<Set<string>>(new Set<string>());
readonly kinds = computed(() => Array.from(this.kindsSet()).sort());
readonly tags = computed(() => Array.from(this.tagsSet()).sort((a, b) => a.localeCompare(b)));
readonly hasAnyKind = computed(() => this.kinds().length > 0);
isKindActive(k: FileKind | 'all'): boolean {
if (k === 'all') return this.kinds().length === 0;
return this.kindsSet().has(k);
}
clearKinds(): void {
if (this.kindsSet().size === 0) return;
this.kindsSet.update(() => new Set<FileKind>());
}
toggleKind(k: FileKind | 'all'): void {
if (k === 'all') {
this.clearKinds();
return;
}
const next = new Set(this.kindsSet());
if (next.has(k)) next.delete(k); else next.add(k);
this.kindsSet.set(next);
}
isTagActive(tag: string): boolean {
const norm = (tag || '').trim().toLowerCase();
return Array.from(this.tagsSet()).some(t => (t || '').trim().toLowerCase() === norm);
}
clearTags(): void {
if (this.tagsSet().size === 0) return;
this.tagsSet.update(() => new Set<string>());
}
toggleTag(tag: string): void {
const current = new Set(this.tagsSet());
// Use display label as-is, but compare case-insensitively for membership
const exists = Array.from(current).some(t => t.trim().toLowerCase() === (tag || '').trim().toLowerCase());
if (exists) {
for (const t of Array.from(current)) {
if (t.trim().toLowerCase() === (tag || '').trim().toLowerCase()) current.delete(t);
}
} else {
current.add(tag);
}
this.tagsSet.set(current);
}
private kindIcon(k: FileKind): string {
switch (k) {
case 'markdown': return '📝';
case 'excalidraw': return '✏️';
case 'pdf': return '📄';
case 'image': return '🖼️';
case 'video': return '🎬';
case 'code': return '</>';
}
}
private quickIcon(name: string): string {
const map: Record<string, string> = {
'Favoris': '❤️',
'Publié': '🌐',
'Modèles': '📑',
'Tâches': '🗒️',
'Brouillons': '📝',
'Privé': '🔒',
'Archive': '🗃️'
};
return map[name] || '⚡';
}
// Combined badges from URL state (tag/folder/quick) + local kinds
readonly badges = computed<FilterBadgeItem[]>(() => {
const out: FilterBadgeItem[] = [];
const urlTag = this.url.activeTag();
const seenTags = new Set<string>();
if (urlTag) {
out.push({ id: `tag:${urlTag}`, type: 'tag', label: urlTag, icon: '🏷️' });
seenTags.add((urlTag || '').trim().toLowerCase());
}
const folder = this.url.activeFolder();
if (folder) {
const parts = (folder || '').split('/').filter(Boolean);
out.push({ id: `folder:${folder}`, type: 'folder', label: parts[parts.length - 1] || folder, icon: '📁' });
}
const quick = this.url.activeQuickLink();
if (quick) out.push({ id: `quick:${quick}`, type: 'quick', label: quick, icon: this.quickIcon(quick) });
for (const k of this.kinds()) {
out.push({ id: `kind:${k}`, type: 'kind', label: k, icon: this.kindIcon(k) });
}
// Local cumulative tags (avoid duplicating URL tag)
for (const t of this.tags()) {
const norm = (t || '').trim().toLowerCase();
if (!seenTags.has(norm)) {
out.push({ id: `tag:${t}`, type: 'tag', label: t, icon: '🏷️' });
}
}
return out;
});
removeBadge(badge: FilterBadgeItem): void {
switch (badge.type) {
case 'tag':
// Remove from local cumulative set if present; otherwise clear URL tag
if (this.isTagActive(badge.label)) {
this.toggleTag(badge.label);
} else {
this.url.updateSearch('');
this.url.clearTagFilter();
}
break;
case 'folder':
this.url.clearFolderFilter();
break;
case 'quick':
this.url.clearQuickLinkFilter();
break;
case 'kind':
this.toggleKind(badge.id.split(':')[1] as FileKind);
break;
}
}
}

View File

@ -0,0 +1,21 @@
import { Injectable, signal } from '@angular/core';
import { UrlStateService } from './url-state.service';
export type SidebarSection = 'quick' | 'folders' | 'tags' | null;
@Injectable({ providedIn: 'root' })
export class SidebarStateService {
private openSectionSig = signal<SidebarSection>(null);
constructor(private url: UrlStateService) {}
openSection() { return this.openSectionSig(); }
open(section: Exclude<SidebarSection, null>) {
if (this.openSectionSig() !== section) {
this.openSectionSig.set(section);
}
// Reset filters/search when switching sections as per UX spec
this.url.showAllAndReset();
}
}

View File

@ -407,6 +407,26 @@ export class UrlStateService implements OnDestroy {
await this.updateUrl({ kind: normalized }); await this.updateUrl({ kind: normalized });
} }
/** Clear only the tag filter */
async clearTagFilter(): Promise<void> {
await this.updateUrl({ tag: null });
}
/** Clear only the folder filter */
async clearFolderFilter(): Promise<void> {
await this.updateUrl({ folder: null });
}
/** Clear only the quick link filter */
async clearQuickLinkFilter(): Promise<void> {
await this.updateUrl({ quick: null });
}
/** Clear only the kind filter (back to 'all') */
async clearKindFilter(): Promise<void> {
await this.updateUrl({ kind: 'all' });
}
/** /**
* Définir la note et optionnellement le dossier (pour création de note) * Définir la note et optionnellement le dossier (pour création de note)
* Utilise merge pour conserver les autres paramètres (search, etc.) * Utilise merge pour conserver les autres paramètres (search, etc.)