diff --git a/src/app/components/filter-badge/filter-badge.component.ts b/src/app/components/filter-badge/filter-badge.component.ts new file mode 100644 index 0000000..77d6177 --- /dev/null +++ b/src/app/components/filter-badge/filter-badge.component.ts @@ -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: ` + + {{ icon }} + {{ label }} + + + + + + + + `, + 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(); +} diff --git a/src/app/features/list/notes-list.component.ts b/src/app/features/list/notes-list.component.ts index f39924e..506ef8e 100644 --- a/src/app/features/list/notes-list.component.ts +++ b/src/app/features/list/notes-list.component.ts @@ -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: ` - - - - Filtre: #{{ t }} - - - - - - - - {{ ql.icon }} {{ ql.name }} - - - - + + + @@ -51,9 +41,10 @@ import { FileTypeDetectorService } from '../../../services/file-type-detector.se - ; + @ViewChild('searchInput') searchInput?: ElementRef; + private urlState = inject(UrlStateService); + private pendingSelectId = signal(null); + private editorState = inject(EditorStateService); + private vault = inject(VaultService); + private fileTypes = inject(FileTypeDetectorService); + filter = inject(FilterService); notes = input([]); folderFilter = input(null); query = input(''); @@ -488,74 +487,49 @@ export class NotesListComponent { @Output() clearQuickLinkFilter = new EventEmitter(); @Output() noteCreated = new EventEmitter(); @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; - private urlState = inject(UrlStateService); - private pendingSelectId = signal(null); - private editorState = inject(EditorStateService); - private vault = inject(VaultService); - private fileTypes = inject(FileTypeDetectorService); + + // Local state + private q = signal(''); + activeTag = signal(null); + sortMenuOpen = signal(false); + viewModeMenuOpen = signal(false); + readonly sortOptions: SortBy[] = ['title', 'created', 'updated']; + readonly viewModes: ViewMode[] = ['compact', 'comfortable', 'detailed']; // Delete warning modal state deleteWarningOpen = signal(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(null); - sortMenuOpen = signal(false); - viewModeMenuOpen = signal(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; - } - this.activeTag.set(this.store.get()); + 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); + } + } 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 = { - '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); } } diff --git a/src/app/features/sidebar/nimbus-sidebar.component.ts b/src/app/features/sidebar/nimbus-sidebar.component.ts index 5771859..5665ef9 100644 --- a/src/app/features/sidebar/nimbus-sidebar.component.ts +++ b/src/app/features/sidebar/nimbus-sidebar.component.ts @@ -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'; + (click)="toggleSection('quick')"> {{ open.quick ? '▾' : '▸' }} ⚡ @@ -71,7 +73,7 @@ import { UrlStateService } from '../../services/url-state.service'; - + {{ open.folders ? '▾' : '▸' }} 📁 Folders @@ -81,32 +83,32 @@ import { UrlStateService } from '../../services/url-state.service'; - - + + 🖼️ 🎬 📄 📝 ✏️ </> ✨ Tout + (click)="toggleSection('tags')"> {{ open.tags ? '▾' : '▸' }} 🏷️ @@ -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 { diff --git a/src/app/layout/app-shell-nimbus/app-shell-nimbus.component.ts b/src/app/layout/app-shell-nimbus/app-shell-nimbus.component.ts index a772b15..be2363d 100644 --- a/src/app/layout/app-shell-nimbus/app-shell-nimbus.component.ts +++ b/src/app/layout/app-shell-nimbus/app-shell-nimbus.component.ts @@ -627,7 +627,10 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit { onQueryChange(query: string) { this.listQuery = query; - this.autoSelectFirstNote(); + // 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); } diff --git a/src/app/services/filter.service.ts b/src/app/services/filter.service.ts new file mode 100644 index 0000000..e9a2282 --- /dev/null +++ b/src/app/services/filter.service.ts @@ -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>(new Set()); + + // Cumulative tags (local only; URL still holds at most one tag for deep links) + private tagsSet = signal>(new Set()); + + 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()); + } + + 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()); + } + + 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 = { + '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(() => { + const out: FilterBadgeItem[] = []; + + const urlTag = this.url.activeTag(); + const seenTags = new Set(); + 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; + } + } +} diff --git a/src/app/services/sidebar-state.service.ts b/src/app/services/sidebar-state.service.ts new file mode 100644 index 0000000..6c59d01 --- /dev/null +++ b/src/app/services/sidebar-state.service.ts @@ -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(null); + + constructor(private url: UrlStateService) {} + + openSection() { return this.openSectionSig(); } + + open(section: Exclude) { + if (this.openSectionSig() !== section) { + this.openSectionSig.set(section); + } + // Reset filters/search when switching sections as per UX spec + this.url.showAllAndReset(); + } +} diff --git a/src/app/services/url-state.service.ts b/src/app/services/url-state.service.ts index 6210654..f5253fa 100644 --- a/src/app/services/url-state.service.ts +++ b/src/app/services/url-state.service.ts @@ -407,6 +407,26 @@ export class UrlStateService implements OnDestroy { await this.updateUrl({ kind: normalized }); } + /** Clear only the tag filter */ + async clearTagFilter(): Promise { + await this.updateUrl({ tag: null }); + } + + /** Clear only the folder filter */ + async clearFolderFilter(): Promise { + await this.updateUrl({ folder: null }); + } + + /** Clear only the quick link filter */ + async clearQuickLinkFilter(): Promise { + await this.updateUrl({ quick: null }); + } + + /** Clear only the kind filter (back to 'all') */ + async clearKindFilter(): Promise { + 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.)