import { Component, EventEmitter, Output, input, signal, computed, effect, inject, OnInit, OnDestroy, ViewChild } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ScrollingModule, CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; import { PaginationService, NoteMetadata } from '../../services/pagination.service'; import { VaultService } from '../../../services/vault.service'; import { FileTypeDetectorService } from '../../../services/file-type-detector.service'; import { TagFilterStore } from '../../core/stores/tag-filter.store'; import { NotesListStateService, SortBy, ViewMode } from '../../services/notes-list-state.service'; import { FilterService } from '../../services/filter.service'; import { NoteContextMenuService } from '../../services/note-context-menu.service'; import { EditorStateService } from '../../../services/editor-state.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 { ScrollableOverlayDirective } from '../../shared/overlay-scrollbar/scrollable-overlay.directive'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; @Component({ selector: 'app-paginated-notes-list', standalone: true, imports: [CommonModule, ScrollingModule, ScrollableOverlayDirective, NoteContextMenuComponent, WarningPanelComponent, FilterBadgeComponent], template: `
Filtre: #{{ t }}
{{ ql.icon }} {{ ql.name }}
{{ visibleNotes().length }}
  • {{ typeIcon(note.filePath) }}
    {{ note.title }}
    {{ typeIcon(note.filePath) }}
    {{ note.title }}
    {{ note.filePath }}
    {{ typeIcon(note.filePath) }}
    {{ note.title }}
    {{ note.filePath }}
  • Chargement...
  • {{ totalLoaded() }} notes chargées
  • Aucune note trouvée
`, styles: [` :host { display: block; height: 100%; min-height: 0; background: var(--list-panel-bg); position: relative; z-index: 0; } /* Subtle vertical depth overlay */ :host::before { content: ""; position: absolute; inset: 0; pointer-events: none; background: linear-gradient( to bottom, color-mix(in oklab, var(--card) 100%, transparent 0%) 0%, color-mix(in oklab, var(--card) 96%, black 0%) 35%, color-mix(in oklab, var(--card) 92%, black 0%) 100% ); opacity: 0.6; } /* Theming variables per color scheme */ :host { --row-radius: 8px; --row-pad-v: 12px; --row-pad-h: 16px; --row-gap: 2px; --active-line: var(--primary, #3b82f6); --meta-color: var(--text-muted); --row-bg: color-mix(in oklab, var(--card) 97%, black 0%); --row-bg-hover: color-mix(in oklab, var(--card) 100%, white 6%); --row-shadow-active: 0 2px 10px color-mix(in oklab, var(--active-line) 18%, transparent 82%); --list-panel-bg: color-mix(in oklab, var(--card) 92%, black 8%); } :host-context(html.dark) { --row-bg: color-mix(in oklab, var(--card) 94%, white 0%); --row-bg-hover: color-mix(in oklab, var(--card) 90%, white 10%); --list-panel-bg: color-mix(in oklab, var(--card) 86%, black 14%); } :host-context(html:not(.dark)) { --row-bg: color-mix(in oklab, var(--card) 94%, black 6%); --row-bg-hover: color-mix(in oklab, var(--card) 90%, black 10%); --list-panel-bg: color-mix(in oklab, var(--card) 96%, black 4%); } cdk-virtual-scroll-viewport { height: 100%; } /* Notes list and rows (match non-virtual list) */ .notes-list { margin: 0; padding: 2px 0; list-style: none; } .note-row { position: relative; margin: var(--row-gap) 1px; border-radius: var(--row-radius); background: var(--row-bg); transition: all 0.2s ease-in-out; min-height: 60px; display: flex; flex-direction: column; justify-content: center; } .note-row:hover { background: var(--row-bg-hover); } .note-row.active::before, .note-row.active::after { content: ""; position: absolute; left: 0; right: 0; height: 2px; background: var(--active-line); border-radius: 2px; } .note-row.active::before { top: 0; } .note-row.active::after { bottom: 0; } .note-row.active { box-shadow: var(--row-shadow-active); } .note-inner { padding: var(--row-pad-v) var(--row-pad-h); } .title { color: var(--text-main, #111); font-weight: 500; } :host-context(html.dark) .title { color: var(--text-main, #e5e7eb); } .note-row.active .title { font-weight: 600; } .meta { color: var(--meta-color, #6b7280); opacity: 0.8; } :host-context(html.dark) .meta { color: var(--meta-color, #94a3b8); opacity: 0.9; } .excerpt { color: var(--meta-color); opacity: 0.75; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; text-overflow: ellipsis; } :host::before { z-index: 0; } /* Action buttons container */ .action-buttons { position: relative; display: flex; align-items: center; justify-content: space-between; width: 100%; gap: 0.5rem; } /* Enhanced note card with color indicator and action buttons */ .note-card { transition: all 0.3s ease-in-out; background-repeat: no-repeat; background-size: 100% 120px; background-position: top center; } .note-card:hover { transform: translateY(-1px); } /* Color dot indicator */ .note-color-dot { width: 8px; height: 8px; border-radius: 50%; box-shadow: 0 0 0 1px color-mix(in oklab, var(--text-main) 15%, transparent 85%); transition: all 0.2s ease-in-out; } .note-row:hover .note-color-dot { box-shadow: 0 0 0 2px color-mix(in oklab, var(--text-main) 25%, transparent 75%); } /* Action buttons */ .note-card-actions { pointer-events: auto; } .action-btn { display: inline-flex; align-items: center; justify-content: center; cursor: pointer; font-size: 0; } .action-btn.edit { color: var(--primary, #3b82f6); } .action-btn.delete { color: #dc2626; } :host-context(html.dark) .action-btn.delete { color: #ef4444; } `] }) export class PaginatedNotesListComponent implements OnInit, OnDestroy { private paginationService = inject(PaginationService); private store = inject(TagFilterStore); private vault = inject(VaultService); private fileTypes = inject(FileTypeDetectorService); readonly state = inject(NotesListStateService); readonly filter = inject(FilterService); readonly contextMenu = inject(NoteContextMenuService); private editorState = inject(EditorStateService); private destroy$ = new Subject(); @ViewChild(CdkVirtualScrollViewport) viewport?: CdkVirtualScrollViewport; // Inputs folderFilter = input(null); query = input(''); tagFilter = input(null); quickLinkFilter = input<'favoris' | 'publish' | 'draft' | 'template' | 'task' | 'private' | 'archive' | null>(null); selectedId = input(null); kindFilter = input<'image' | 'video' | 'pdf' | 'markdown' | 'excalidraw' | 'code' | 'all' | null>(null); // Outputs @Output() openNote = new EventEmitter(); @Output() queryChange = new EventEmitter(); @Output() clearQuickLinkFilter = new EventEmitter(); // Local state private q = signal(''); selectedNoteId = signal(null); 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 deleteTargetId: string | null = null; // Pagination state paginatedNotes = this.paginationService.allItems; isLoadingMore = this.paginationService.isLoadingMore; hasMore = this.paginationService.hasMore; totalLoaded = this.paginationService.totalLoaded; canLoadMore = this.paginationService.canLoadMore; // Visible notes with fallback and filters visibleNotes = computed(() => { let items = this.paginatedNotes(); let usedFallback = false; const vaultNotes = (() => { try { return this.vault.allNotes() || []; } catch { return []; } })(); const byId = new Map(vaultNotes.map(n => [n.id, n])); if (!items || items.length === 0) { try { const all = this.vault.allNotes(); items = (all || []).map(n => ({ id: n.id, title: n.title, filePath: n.filePath, createdAt: n.createdAt as any, updatedAt: (n.updatedAt as any) || (n.mtime ? new Date(n.mtime).toISOString() : '') })); usedFallback = true; } catch { items = []; } } // Folder filter const folder = (this.folderFilter() || '').toLowerCase().replace(/^\/+|\/+$/g, ''); if (folder) { if (folder === '.trash') { items = items.filter(n => { const fp = (n.filePath || '').toLowerCase().replace(/\\/g, '/'); return fp.startsWith('.trash/') || fp.includes('/.trash/'); }); } else { items = items.filter(n => { const op = (n.filePath || '').toLowerCase().replace(/^\/+|\/+$/g, ''); return op === folder || op.startsWith(folder + '/'); }); } } else { // Exclude trash by default items = items.filter(n => { const fp = (n.filePath || '').toLowerCase().replace(/\\/g, '/'); return !fp.startsWith('.trash/') && !fp.includes('/.trash/'); }); } // Kind filters (FilterService multi-kinds first; fallback to single kindFilter) const kinds = this.filter.kinds(); const urlKind = this.kindFilter(); let allowedKinds = new Set(kinds.length > 0 ? kinds : (urlKind && urlKind !== 'all' ? [urlKind] : [])); // Folder/Trash views must show all types unless quick/tag constrain to markdown const folderActive = !!folder; const quickActive = !!this.quickLinkFilter(); const tagActive = !!(this.tagFilter() || '').trim() || this.filter.tags().length > 0; if (folderActive && !quickActive && !tagActive) { allowedKinds = new Set(); // no restriction in folder/trash } if (allowedKinds.size > 0) { items = items.filter(n => Array.from(allowedKinds).some(k => this.matchesKind(n.filePath, k as any))); } // Query filtering (always apply client-side as extra guard) const q = (this.q() || '').toLowerCase().trim(); if (q) { items = items.filter(n => (n.title || '').toLowerCase().includes(q) || (n.filePath || '').toLowerCase().includes(q)); } // Tag and Quick Link filters using vault metadata when available const urlTag = (this.tagFilter() || '').toLowerCase(); const localTags = this.filter.tags().map(t => (t || '').toLowerCase()); const quick = this.quickLinkFilter(); if (urlTag || localTags.length > 0) { items = items.filter(n => { const full = byId.get(n.id); const ntags: string[] = Array.isArray(full?.tags) ? full.tags.map((t: string) => (t || '').toLowerCase()) : []; if (urlTag && !ntags.includes(urlTag)) return false; for (const t of localTags) { if (!ntags.includes(t)) return false; } // Tags view must show markdown only return this.matchesKind(n.filePath, 'markdown'); }); } if (quick) { items = items.filter(n => { const full = byId.get(n.id); const fm = full?.frontmatter || {}; return fm[quick] === true && this.matchesKind(n.filePath, 'markdown'); }); } // If allowed kinds include any non-markdown type OR no kinds selected at all (default 'all'), // ensure those files appear even if pagination didn't include them (server may return only markdown) const needMergeForKinds = (allowedKinds.size > 0 && Array.from(allowedKinds).some(k => k !== 'markdown')) || (allowedKinds.size === 0 && !quick && !tagActive); // default 'all' and no quick/tag constraint if (needMergeForKinds) { const present = new Set(items.map(n => n.id)); for (const full of vaultNotes) { const t = this.fileTypes.getViewerType(full.filePath, full.rawContent ?? full.content ?? ''); const allowByKind = allowedKinds.size === 0 ? true : allowedKinds.has(t); if (allowByKind && !present.has(full.id)) { // Apply same folder filter and tag/quick constraints const fp = (full.filePath || '').toLowerCase().replace(/\\/g, '/'); const op = (full.filePath || '').toLowerCase().replace(/^\/+|\/+$/g, ''); const includeByFolder = folder ? (folder === '.trash' ? (fp.startsWith('.trash/') || fp.includes('/.trash/')) : (op === folder || op.startsWith(folder + '/'))) : (!fp.startsWith('.trash/') && !fp.includes('/.trash/')); if (!includeByFolder) continue; const ntags: string[] = Array.isArray(full.tags) ? full.tags.map((x: string) => (x || '').toLowerCase()) : []; if (urlTag && !ntags.includes(urlTag)) continue; let okLocal = true; for (const t of localTags) { if (!ntags.includes(t)) { okLocal = false; break; } } if (!okLocal) continue; if (quick) { const fm = full.frontmatter || {}; if (fm[quick] !== true) continue; } if (q) { const titleLc = (full.title || '').toLowerCase(); const pathLc = (full.filePath || '').toLowerCase(); if (!titleLc.includes(q) && !pathLc.includes(q)) continue; } items.push({ id: full.id, title: full.title, filePath: full.filePath, createdAt: (full as any).createdAt, updatedAt: (full as any).updatedAt || (full.mtime ? new Date(full.mtime).toISOString() : '') }); present.add(full.id); } } } // Sorting (title/created/updated) like old list const parseDate = (s?: string) => (s ? Date.parse(s) : 0) || 0; const sortBy = this.state.sortBy(); items = [...items].sort((a, b) => { switch (sortBy) { case 'title': return (a.title || '').localeCompare(b.title || ''); case 'created': return parseDate(b.createdAt) - parseDate(a.createdAt); case 'updated': default: { const mb = byId.get(b.id)?.mtime; const ma = byId.get(a.id)?.mtime; const ub = parseDate(b.updatedAt) || (mb ? Number(mb) : 0); const ua = parseDate(a.updatedAt) || (ma ? Number(ma) : 0); return ub - ua; } } }); return items; }); // Effects private syncQuery = effect(() => { this.q.set(this.query() || ''); // If external query changes (e.g., URL/state), refresh pagination to match const current = this.paginationService.getSearchTerm(); const next = this.query() || ''; if (current !== next) { this.paginationService.search(next); } }); private syncTagFromStore = effect(() => { const inputTag = this.tagFilter(); if (inputTag !== null && inputTag !== undefined) { this.activeTag.set(inputTag || null); return; } this.activeTag.set(this.store.get()); }); ngOnInit() { // Load initial page with incoming query this.paginationService.loadInitial(this.query() || ''); } ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); } // Handle virtual scroll onScroll(index: number) { const items = this.visibleNotes(); // Load more when approaching the end (20 items before the end) if (index > items.length - 20 && this.canLoadMore()) { this.paginationService.loadNextPage(); } } // Select a note selectNote(note: NoteMetadata) { this.selectedNoteId.set(note.id); this.openNote.emit(note.id); } // Search onQuery(v: string) { this.q.set(v); this.queryChange.emit(v); // Trigger search with pagination this.paginationService.search(v); } onSearchEnter(): void { const first = this.visibleNotes()[0]; if (first) this.openNote.emit(first.id); } // Clear tag filter clearTagFilter(): void { this.activeTag.set(null); if (this.tagFilter() == null) { this.store.set(null); } } // Track by function for virtual scroll trackByFn(index: number, item: NoteMetadata): string { return item.id; } // Quick link display 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; } // Helpers private matchesKind(filePath: string, kind: 'image' | 'video' | 'pdf' | 'markdown' | 'excalidraw' | 'code'): boolean { try { const t = this.fileTypes.getViewerType(filePath, ''); return t === kind; } catch { return true; } } // UI helpers getListItemClasses(): string { const mode = this.state.viewMode(); if (mode === 'compact') return 'px-3 py-1.5'; if (mode === 'detailed') return 'p-3 space-y-1.5'; return 'p-3'; } // Color and gradient private getFullNoteById(id: string): any | null { try { const n = (this.vault as any).getNoteById?.(id); if (n) return n; } catch {} try { const list = this.vault.allNotes() || []; for (const n of list) if ((n as any).id === id) return n; } catch {} return null; } getNoteColorById(id: string): string { const full = this.getFullNoteById(id); return full?.frontmatter?.color || 'var(--text-muted)'; } getNoteGradientStyleById(id: string): Record | null { const full = this.getFullNoteById(id); const color = full?.frontmatter?.color; if (!color) return null; 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)`; } return { backgroundImage: `linear-gradient(to left, ${gradientColor} 0%, transparent 65%)` } as Record; } typeIcon(filePath: string): string { try { const t = this.fileTypes.getViewerType(filePath, ''); switch (t) { case 'markdown': return '📝'; case 'excalidraw': return '✏️'; case 'pdf': return '📄'; case 'image': return '🖼️'; case 'video': return '🎬'; case 'code': return ''; default: return '📎'; } } catch { return '📎'; } } // Sort/View menus toggleSortMenu(): void { this.sortMenuOpen.set(!this.sortMenuOpen()); this.viewModeMenuOpen.set(false); } toggleViewModeMenu(): void { this.viewModeMenuOpen.set(!this.viewModeMenuOpen()); this.sortMenuOpen.set(false); } setSortBy(sort: SortBy): void { this.state.setSortBy(sort); this.sortMenuOpen.set(false); } setViewMode(mode: ViewMode): void { this.state.setViewMode(mode); this.viewModeMenuOpen.set(false); } getSortLabel(sort: SortBy): string { const labels: Record = { title: 'Titre', created: 'Date création', updated: 'Date modification' }; return labels[sort]; } getViewModeLabel(mode: ViewMode): string { const labels: Record = { compact: 'Compact', comfortable: 'Confortable', detailed: 'Détaillé' }; return labels[mode]; } // Context menu and delete openContextMenu(event: MouseEvent, noteId: string) { event.preventDefault(); event.stopPropagation(); const full = this.getFullNoteById(noteId); if (full) this.contextMenu.openForNote(full, { x: event.clientX, y: event.clientY }); } async onContextMenuAction(action: string) { const note = this.contextMenu.targetNote(); if (!note) return; switch (action) { case 'duplicate': await this.contextMenu.duplicateNote(note); break; case 'share': await this.contextMenu.shareNote(note); break; case 'fullscreen': this.contextMenu.openFullScreen(note); break; case 'copy-link': await this.contextMenu.copyInternalLink(note); break; case 'favorite': await this.contextMenu.toggleFavorite(note); break; case 'info': this.contextMenu.showPageInfo(note); break; case 'readonly': await this.contextMenu.toggleReadOnly(note); break; case 'delete': this.openDeleteWarningById(note.id); break; } } async onContextMenuColor(color: string) { const note = this.contextMenu.targetNote(); if (!note) return; await this.contextMenu.changeNoteColor(note, color); } openDeleteWarning(note: NoteMetadata) { this.openDeleteWarningById(note.id); } openDeleteWarningById(id: string) { this.deleteTargetId = id; this.deleteWarningOpen.set(true); } closeDeleteWarning() { this.deleteWarningOpen.set(false); this.deleteTargetId = null; } async confirmDelete() { const id = this.deleteTargetId; if (!id) { this.closeDeleteWarning(); return; } const full = this.getFullNoteById(id); if (!full) { this.closeDeleteWarning(); return; } try { await this.contextMenu.deleteNoteConfirmed(full); this.closeDeleteWarning(); this.contextMenu.close(); } catch {} } // Edit editNote(note: NoteMetadata): void { try { const full = this.getFullNoteById(note.id); if (full?.filePath) { const content = (full as any).rawContent ?? full.content ?? ''; this.editorState.enterEditMode(full.filePath, content); this.openNote.emit(note.id); } } catch { this.openNote.emit(note.id); } } // Scroll selected into view private scrollToSelectedEffect = effect(() => { const id = this.selectedId(); if (!id || !this.viewport) return; const idx = this.visibleNotes().findIndex(n => n.id === id); if (idx >= 0) { try { this.viewport.scrollToIndex(idx, 'smooth'); } catch {} } }); }