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:
parent
6f01d65411
commit
58b22a47c9
41
src/app/components/filter-badge/filter-badge.component.ts
Normal file
41
src/app/components/filter-badge/filter-badge.component.ts
Normal 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>();
|
||||
}
|
||||
@ -8,6 +8,8 @@ import { NotesListStateService, SortBy, ViewMode } from '../../services/notes-li
|
||||
import { NoteCreationService } from '../../services/note-creation.service';
|
||||
import { NoteContextMenuComponent } from '../../../components/note-context-menu/note-context-menu.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 { UrlStateService } from '../../services/url-state.service';
|
||||
import { EditorStateService } from '../../../services/editor-state.service';
|
||||
@ -17,28 +19,16 @@ import { FileTypeDetectorService } from '../../../services/file-type-detector.se
|
||||
@Component({
|
||||
selector: 'app-notes-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ScrollableOverlayDirective, NoteContextMenuComponent, WarningPanelComponent],
|
||||
imports: [CommonModule, ScrollableOverlayDirective, NoteContextMenuComponent, WarningPanelComponent, FilterBadgeComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="h-full flex flex-col">
|
||||
<!-- Header with filters -->
|
||||
<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">
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-surface1 dark:bg-card text-main dark:text-main px-2 py-1">
|
||||
<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>
|
||||
Filtre: #{{ t }}
|
||||
</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>
|
||||
<!-- Unified badges row (tag, folder, quick, kinds) -->
|
||||
<div class="flex flex-wrap items-center gap-1.5 min-h-[1.75rem]">
|
||||
<app-filter-badge *ngFor="let b of filter.badges()"
|
||||
[label]="b.label" [icon]="b.icon" (remove)="filter.removeBadge(b)"></app-filter-badge>
|
||||
</div>
|
||||
|
||||
<!-- Path Indicator with Sort and View Mode Menus -->
|
||||
@ -51,9 +41,10 @@ import { FileTypeDetectorService } from '../../../services/file-type-detector.se
|
||||
|
||||
<!-- Search and New Note -->
|
||||
<div class="flex gap-2 items-center">
|
||||
<input type="text"
|
||||
<input #searchInput type="text"
|
||||
[value]="query()"
|
||||
(input)="onQuery($any($event.target).value)"
|
||||
(keydown.enter)="onSearchEnter()"
|
||||
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" />
|
||||
<button type="button"
|
||||
@ -475,6 +466,14 @@ import { FileTypeDetectorService } from '../../../services/file-type-detector.se
|
||||
`]
|
||||
})
|
||||
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[]>([]);
|
||||
folderFilter = input<string | null>(null);
|
||||
query = input<string>('');
|
||||
@ -489,73 +488,48 @@ export class NotesListComponent {
|
||||
@Output() noteCreated = new EventEmitter<string>();
|
||||
@Output() noteCreatedAndSelected = new EventEmitter<{ id: string; filePath: string }>();
|
||||
|
||||
// Stores and services
|
||||
private store = inject(TagFilterStore);
|
||||
readonly state = inject(NotesListStateService);
|
||||
private noteCreationService = inject(NoteCreationService);
|
||||
readonly contextMenuService = inject(NoteContextMenuService);
|
||||
@ViewChild('listContainer') listContainer?: ElementRef<HTMLElement>;
|
||||
private urlState = inject(UrlStateService);
|
||||
private pendingSelectId = signal<string | null>(null);
|
||||
private editorState = inject(EditorStateService);
|
||||
private vault = inject(VaultService);
|
||||
private fileTypes = inject(FileTypeDetectorService);
|
||||
|
||||
// Local state
|
||||
private q = signal('');
|
||||
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'];
|
||||
|
||||
// Delete warning modal state
|
||||
deleteWarningOpen = signal<boolean>(false);
|
||||
private deleteTarget: Note | null = null;
|
||||
|
||||
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.deleteTarget = note;
|
||||
this.deleteWarningOpen.set(true);
|
||||
}
|
||||
|
||||
closeDeleteWarning() {
|
||||
console.log('[NotesList] Closing delete warning');
|
||||
this.deleteWarningOpen.set(false);
|
||||
this.deleteTarget = null;
|
||||
}
|
||||
|
||||
async confirmDelete() {
|
||||
console.log('[NotesList] Confirm delete called for:', this.deleteTarget?.title);
|
||||
const note = this.deleteTarget;
|
||||
if (!note) {
|
||||
console.warn('[NotesList] No delete target found');
|
||||
this.closeDeleteWarning();
|
||||
return;
|
||||
}
|
||||
if (!note) { this.closeDeleteWarning(); return; }
|
||||
try {
|
||||
console.log('[NotesList] Calling deleteNoteConfirmed...');
|
||||
await this.contextMenuService.deleteNoteConfirmed(note);
|
||||
// Only close on success
|
||||
console.log('[NotesList] Delete successful, closing modal');
|
||||
this.closeDeleteWarning();
|
||||
this.contextMenuService.close();
|
||||
} catch (error) {
|
||||
console.error('Confirm delete error:', error);
|
||||
// Keep modal open on error so user can try again or cancel
|
||||
} catch (e) {
|
||||
console.error('Confirm delete error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
private q = signal('');
|
||||
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);
|
||||
});
|
||||
|
||||
// Helpers from original component
|
||||
private buildUnifiedList(): Note[] {
|
||||
const notes = this.notes();
|
||||
const notePaths = new Set(notes.map(n => (n.filePath || '').toLowerCase().replace(/\\/g, '/')));
|
||||
@ -631,6 +605,8 @@ export class NotesListComponent {
|
||||
}
|
||||
|
||||
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();
|
||||
if (!id) return;
|
||||
const host = this.listContainer?.nativeElement;
|
||||
@ -646,12 +622,8 @@ export class NotesListComponent {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Attempt after microtask, then a few RAF retries to wait for DOM
|
||||
queueMicrotask(() => {
|
||||
if (tryFocus()) {
|
||||
// If parent hasn't yet reflected selection, keep local pending highlight
|
||||
return;
|
||||
}
|
||||
if (tryFocus()) return;
|
||||
let attempts = 4;
|
||||
const raf = () => {
|
||||
if (tryFocus()) return;
|
||||
@ -669,19 +641,46 @@ export class NotesListComponent {
|
||||
}
|
||||
});
|
||||
|
||||
private syncTagFromStore = effect(() => {
|
||||
const inputTag = this.tagFilter();
|
||||
if (inputTag !== null && inputTag !== undefined) {
|
||||
this.activeTag.set(inputTag || null);
|
||||
return;
|
||||
private keepSearchFocusEffect = effect(() => {
|
||||
const input = this.searchInput?.nativeElement;
|
||||
if (!input) return;
|
||||
if (!(this.q() || '')) return; // only enforce while searching
|
||||
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(() => {
|
||||
const q = (this.q() || '').toLowerCase().trim();
|
||||
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 kind = this.kindFilter();
|
||||
const sortBy = this.state.sortBy();
|
||||
@ -709,14 +708,21 @@ export class NotesListComponent {
|
||||
}
|
||||
}
|
||||
|
||||
if (tag) {
|
||||
list = list.filter(n => Array.isArray(n.tags) && n.tags.some(t => (t || '').toLowerCase() === tag));
|
||||
// Tags: cumulative AND filter across URL tag + local tags
|
||||
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) {
|
||||
const fmKey = this.mapInternalQuickToFrontmatter(quickLink);
|
||||
list = list.filter(n => {
|
||||
const frontmatter = n.frontmatter || {};
|
||||
return frontmatter[quickLink] === true;
|
||||
const frontmatter = n.frontmatter || {} as any;
|
||||
return fmKey ? frontmatter[fmKey] === true : false;
|
||||
});
|
||||
}
|
||||
|
||||
@ -729,7 +735,10 @@ export class NotesListComponent {
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
@ -748,28 +757,10 @@ export class NotesListComponent {
|
||||
});
|
||||
});
|
||||
|
||||
getQuickLinkDisplay(quickLink: string): { icon: string; name: string } | null {
|
||||
const displays: Record<string, { icon: string; name: string }> = {
|
||||
'favoris': { icon: '❤️', name: 'Favoris' },
|
||||
'publish': { icon: '🌐', name: 'Publish' },
|
||||
'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);
|
||||
onSearchEnter(): void {
|
||||
const first = this.filtered()[0];
|
||||
if (first) {
|
||||
this.openNote.emit(first.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -8,6 +8,8 @@ import type { VaultNode, TagInfo } from '../../../types';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { VaultService } from '../../../services/vault.service';
|
||||
import { UrlStateService } from '../../services/url-state.service';
|
||||
import { SidebarStateService } from '../../services/sidebar-state.service';
|
||||
import { FilterService } from '../../services/filter.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-nimbus-sidebar',
|
||||
@ -56,7 +58,7 @@ import { UrlStateService } from '../../services/url-state.service';
|
||||
<!-- Quick Links accordion -->
|
||||
<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"
|
||||
(click)="open.quick = !open.quick">
|
||||
(click)="toggleSection('quick')">
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="text-xs text-muted">{{ open.quick ? '▾' : '▸' }}</span>
|
||||
<span>⚡</span>
|
||||
@ -71,7 +73,7 @@ import { UrlStateService } from '../../services/url-state.service';
|
||||
<!-- Folders accordion -->
|
||||
<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">
|
||||
<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>📁</span>
|
||||
<span>Folders</span>
|
||||
@ -81,32 +83,32 @@ import { UrlStateService } from '../../services/url-state.service';
|
||||
</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">
|
||||
<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" />
|
||||
</svg>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
<div *ngIf="open.folders" class="px-1 py-1">
|
||||
<div class="flex gap-2 flex-wrap px-2 pb-2">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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"></></button>
|
||||
<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>
|
||||
</div>
|
||||
<app-file-explorer
|
||||
@ -124,7 +126,7 @@ import { UrlStateService } from '../../services/url-state.service';
|
||||
<!-- Tags accordion -->
|
||||
<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"
|
||||
(click)="open.tags = !open.tags">
|
||||
(click)="toggleSection('tags')">
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="text-xs text-muted">{{ open.tags ? '▾' : '▸' }}</span>
|
||||
<span>🏷️</span>
|
||||
@ -218,6 +220,8 @@ export class NimbusSidebarComponent implements OnChanges {
|
||||
open = { quick: true, folders: true, tags: false, trash: false, tests: false };
|
||||
private vault = inject(VaultService);
|
||||
urlState = inject(UrlStateService);
|
||||
private sidebar = inject(SidebarStateService);
|
||||
filters = inject(FilterService);
|
||||
@ViewChild('foldersExplorer') private foldersExplorer?: FileExplorerComponent;
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
@ -248,12 +252,11 @@ export class NimbusSidebarComponent implements OnChanges {
|
||||
trashHasContent = () => (this.vault.trashTree() || []).length > 0;
|
||||
trackNoteId = (_: number, n: { id: string }) => n.id;
|
||||
|
||||
toggleFoldersSection(): void {
|
||||
const next = !this.open.folders;
|
||||
this.open.folders = next;
|
||||
if (next) {
|
||||
this.quickLinkSelected.emit('all');
|
||||
}
|
||||
toggleSection(which: 'quick' | 'folders' | 'tags'): void {
|
||||
// Open requested section, close others, and reset filters/search via SidebarStateService
|
||||
this.open = { quick: false, folders: false, tags: false, trash: false, tests: false };
|
||||
(this.open as any)[which] = true;
|
||||
this.sidebar.open(which);
|
||||
}
|
||||
|
||||
onCreateFolderAtRoot(): void {
|
||||
@ -275,7 +278,7 @@ export class NimbusSidebarComponent implements OnChanges {
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@ -627,7 +627,10 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
|
||||
|
||||
onQueryChange(query: string) {
|
||||
this.listQuery = query;
|
||||
// Only auto-select when query is cleared; while typing keep focus in search (handled by notes-list)
|
||||
if (!query) {
|
||||
this.autoSelectFirstNote();
|
||||
}
|
||||
// Sync URL search term
|
||||
this.urlState.updateSearch(query);
|
||||
}
|
||||
|
||||
153
src/app/services/filter.service.ts
Normal file
153
src/app/services/filter.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
21
src/app/services/sidebar-state.service.ts
Normal file
21
src/app/services/sidebar-state.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -407,6 +407,26 @@ export class UrlStateService implements OnDestroy {
|
||||
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)
|
||||
* Utilise merge pour conserver les autres paramètres (search, etc.)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user