feat: enhance notes list with filtering, sorting and view modes
- Added comprehensive filtering system with support for file types, tags, and quick links - Implemented sort options (title, created, updated) with dropdown menu - Added three view modes (compact, comfortable, detailed) for different density layouts - Added note color indicators and hover actions (edit, delete) to note cards - Integrated file type detection with appropriate icons for different content types - Added filter badges to show
This commit is contained in:
@@ -2,7 +2,16 @@ import { Component, EventEmitter, Output, input, signal, computed, effect, injec
|
||||
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';
|
||||
@@ -10,11 +19,16 @@ import { takeUntil } from 'rxjs/operators';
|
||||
@Component({
|
||||
selector: 'app-paginated-notes-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ScrollingModule, ScrollableOverlayDirective],
|
||||
imports: [CommonModule, ScrollingModule, ScrollableOverlayDirective, NoteContextMenuComponent, WarningPanelComponent, FilterBadgeComponent],
|
||||
template: `
|
||||
<div class="h-full flex flex-col">
|
||||
<!-- Search and filters header -->
|
||||
<div class="p-2 border-b border-border dark:border-gray-800 space-y-2">
|
||||
<!-- Unified badges row -->
|
||||
<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>
|
||||
<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>
|
||||
@@ -35,8 +49,37 @@ import { takeUntil } from 'rxjs/operators';
|
||||
<input type="text"
|
||||
[value]="query()"
|
||||
(input)="onQuery($any($event.target).value)"
|
||||
(keydown.enter)="onSearchEnter()"
|
||||
placeholder="Rechercher..."
|
||||
class="w-full 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" />
|
||||
|
||||
<!-- Action Buttons (Sort + View Mode) -->
|
||||
<div class="action-buttons flex justify-between items-center">
|
||||
<div class="flex items-center gap-2 relative">
|
||||
<button type="button" (click)="toggleSortMenu()" class="inline-flex items-center justify-center w-8 h-8 rounded hover:bg-surface2/50 dark:hover:bg-surface2/50 transition-colors" title="Trier par">
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M7 12h10"/><path d="M10 18h4"/></svg>
|
||||
</button>
|
||||
<button type="button" (click)="toggleViewModeMenu()" class="inline-flex items-center justify-center w-8 h-8 rounded hover:bg-surface2/50 dark:hover:bg-surface2/50 transition-colors" title="Mode d'affichage">
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
||||
</button>
|
||||
|
||||
<!-- Sort Dropdown -->
|
||||
<div *ngIf="sortMenuOpen()" class="absolute top-full left-0 mt-1 bg-card dark:bg-main border border-border dark:border-gray-700 rounded shadow-lg z-10 min-w-max">
|
||||
<button type="button" *ngFor="let s of sortOptions" (click)="setSortBy(s)" [class.bg-surface1]="state.sortBy() === s" class="w-full text-left px-3 py-2 text-xs hover:bg-surface1 dark:hover:bg-surface2 transition-colors">{{ getSortLabel(s) }}</button>
|
||||
</div>
|
||||
|
||||
<!-- View Mode Dropdown -->
|
||||
<div *ngIf="viewModeMenuOpen()" class="absolute top-full left-10 mt-1 bg-card dark:bg-main border border-border dark:border-gray-700 rounded shadow-lg z-10 min-w-max">
|
||||
<button type="button" *ngFor="let m of viewModes" (click)="setViewMode(m)" [class.bg-surface1]="state.viewMode() === m" class="w-full text-left px-3 py-2 text-xs hover:bg-surface1 dark:hover:bg-surface2 transition-colors">{{ getViewModeLabel(m) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Count -->
|
||||
<div class="flex items-center gap-1 text-xs text-muted">
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="9"/></svg>
|
||||
{{ visibleNotes().length }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Virtual scroll viewport -->
|
||||
@@ -50,13 +93,50 @@ import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
<ul class="notes-list">
|
||||
<!-- Virtual items -->
|
||||
<li *cdkVirtualFor="let note of paginatedNotes(); trackBy: trackByFn"
|
||||
class="note-row cursor-pointer"
|
||||
<li *cdkVirtualFor="let note of visibleNotes(); trackBy: trackByFn"
|
||||
class="note-row note-card group cursor-pointer relative"
|
||||
[ngClass]="getListItemClasses()"
|
||||
[ngStyle]="getNoteGradientStyleById(note.id)"
|
||||
[attr.data-note-id]="note.id"
|
||||
[class.active]="(selectedId() ?? selectedNoteId()) === note.id"
|
||||
(click)="selectNote(note)">
|
||||
<div class="note-inner">
|
||||
<div class="title text-sm truncate">{{ note.title }}</div>
|
||||
<div class="meta text-xs truncate">{{ note.filePath }}</div>
|
||||
(click)="selectNote(note)"
|
||||
(contextmenu)="openContextMenu($event, note.id)">
|
||||
|
||||
<!-- Action Buttons (hover reveal) -->
|
||||
<div class="note-card-actions absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-10">
|
||||
<button type="button" class="action-btn edit inline-flex items-center justify-center w-7 h-7 rounded-lg bg-card/80 dark:bg-main/80 hover:bg-surface1 dark:hover:bg-surface2 transition-colors backdrop-blur-sm" title="Éditer la note" (click)="$event.stopPropagation(); editNote(note)">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
</button>
|
||||
<button type="button" class="action-btn delete inline-flex items-center justify-center w-7 h-7 rounded-lg bg-card/80 dark:bg-main/80 hover:bg-red-100 dark:hover:bg-red-950 transition-colors backdrop-blur-sm" title="Supprimer la note" (click)="$event.stopPropagation(); openDeleteWarning(note)">
|
||||
<svg class="w-4 h-4 text-red-600 dark:text-red-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Compact View -->
|
||||
<div *ngIf="state.viewMode() === 'compact'" class="note-inner flex items-center gap-2">
|
||||
<span class="note-color-dot flex-shrink-0" [style.backgroundColor]="getNoteColorById(note.id)" aria-hidden="true"></span>
|
||||
<span class="flex-shrink-0" title="Type">{{ typeIcon(note.filePath) }}</span>
|
||||
<div class="title text-xs truncate">{{ note.title }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Comfortable View (default) -->
|
||||
<div *ngIf="state.viewMode() === 'comfortable'" class="note-inner flex items-start gap-2">
|
||||
<span class="note-color-dot flex-shrink-0 mt-1" [style.backgroundColor]="getNoteColorById(note.id)" aria-hidden="true"></span>
|
||||
<span class="flex-shrink-0 mt-0.5" title="Type">{{ typeIcon(note.filePath) }}</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="title text-sm truncate">{{ note.title }}</div>
|
||||
<div class="meta text-xs truncate">{{ note.filePath }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detailed View -->
|
||||
<div *ngIf="state.viewMode() === 'detailed'" class="note-inner flex items-start gap-2 space-y-0">
|
||||
<span class="note-color-dot flex-shrink-0 mt-1" [style.backgroundColor]="getNoteColorById(note.id)" aria-hidden="true"></span>
|
||||
<span class="flex-shrink-0 mt-0.5" title="Type">{{ typeIcon(note.filePath) }}</span>
|
||||
<div class="min-w-0 flex-1 space-y-1.5">
|
||||
<div class="title text-sm truncate">{{ note.title }}</div>
|
||||
<div class="meta text-xs truncate">{{ note.filePath }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -81,6 +161,28 @@ import { takeUntil } from 'rxjs/operators';
|
||||
</cdk-virtual-scroll-viewport>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Note Context Menu -->
|
||||
<app-note-context-menu
|
||||
[x]="contextMenu.x()"
|
||||
[y]="contextMenu.y()"
|
||||
[visible]="contextMenu.visible()"
|
||||
[note]="contextMenu.targetNote()"
|
||||
(action)="onContextMenuAction($event)"
|
||||
(color)="onContextMenuColor($event)"
|
||||
(closed)="contextMenu.close()">
|
||||
</app-note-context-menu>
|
||||
|
||||
<!-- Delete Warning Modal -->
|
||||
<app-warning-panel
|
||||
[visible]="deleteWarningOpen()"
|
||||
[title]="'Delete this note?'"
|
||||
[message]="'The note will be moved to the trash folder and can be restored later.'"
|
||||
[confirmText]="'Delete'"
|
||||
[cancelText]="'Cancel'"
|
||||
[confirmColor]="'danger'"
|
||||
(delete)="confirmDelete()"
|
||||
(cancel)="closeDeleteWarning()"></app-warning-panel>
|
||||
`,
|
||||
styles: [`
|
||||
:host {
|
||||
@@ -178,11 +280,35 @@ import { takeUntil } from 'rxjs/operators';
|
||||
: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<void>();
|
||||
|
||||
@ViewChild(CdkVirtualScrollViewport) viewport?: CdkVirtualScrollViewport;
|
||||
@@ -193,6 +319,7 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
|
||||
tagFilter = input<string | null>(null);
|
||||
quickLinkFilter = input<'favoris' | 'publish' | 'draft' | 'template' | 'task' | 'private' | 'archive' | null>(null);
|
||||
selectedId = input<string | null>(null);
|
||||
kindFilter = input<'image' | 'video' | 'pdf' | 'markdown' | 'excalidraw' | 'code' | 'all' | null>(null);
|
||||
|
||||
// Outputs
|
||||
@Output() openNote = new EventEmitter<string>();
|
||||
@@ -203,6 +330,14 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
|
||||
private q = signal('');
|
||||
selectedNoteId = signal<string | null>(null);
|
||||
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 deleteTargetId: string | null = null;
|
||||
|
||||
// Pagination state
|
||||
paginatedNotes = this.paginationService.allItems;
|
||||
@@ -211,9 +346,167 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
|
||||
totalLoaded = this.paginationService.totalLoaded;
|
||||
canLoadMore = this.paginationService.canLoadMore;
|
||||
|
||||
// Visible notes with fallback and filters
|
||||
visibleNotes = computed<NoteMetadata[]>(() => {
|
||||
let items = this.paginatedNotes();
|
||||
let usedFallback = false;
|
||||
const vaultNotes = (() => {
|
||||
try { return this.vault.allNotes() || []; } catch { return []; }
|
||||
})();
|
||||
const byId = new Map<string, any>(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<string>(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<string>(); // 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(() => {
|
||||
@@ -226,8 +519,8 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
|
||||
ngOnInit() {
|
||||
// Load initial page
|
||||
this.paginationService.loadInitial();
|
||||
// Load initial page with incoming query
|
||||
this.paginationService.loadInitial(this.query() || '');
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
@@ -237,7 +530,7 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
|
||||
|
||||
// Handle virtual scroll
|
||||
onScroll(index: number) {
|
||||
const items = this.paginatedNotes();
|
||||
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();
|
||||
@@ -258,6 +551,11 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
|
||||
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);
|
||||
@@ -284,4 +582,144 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
|
||||
};
|
||||
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<string, string> | 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<string, string>;
|
||||
}
|
||||
|
||||
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<SortBy, string> = { title: 'Titre', created: 'Date création', updated: 'Date modification' };
|
||||
return labels[sort];
|
||||
}
|
||||
getViewModeLabel(mode: ViewMode): string {
|
||||
const labels: Record<ViewMode, string> = { 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 {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { AppBottomNavigationComponent } from '../../features/bottom-nav/app-bott
|
||||
import { AppSidebarDrawerComponent } from '../../features/sidebar/app-sidebar-drawer.component';
|
||||
import { AppTocOverlayComponent } from '../../features/note-view/app-toc-overlay.component';
|
||||
import { SwipeNavDirective } from '../../shared/directives/swipe-nav.directive';
|
||||
import { NotesListComponent } from '../../features/list/notes-list.component';
|
||||
import { PaginatedNotesListComponent } from '../../features/list/paginated-notes-list.component';
|
||||
import { NimbusSidebarComponent } from '../../features/sidebar/nimbus-sidebar.component';
|
||||
import { QuickLinksComponent } from '../../features/quick-links/quick-links.component';
|
||||
import { ScrollableOverlayDirective } from '../../shared/overlay-scrollbar/scrollable-overlay.directive';
|
||||
@@ -30,7 +30,7 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
|
||||
@Component({
|
||||
selector: 'app-shell-nimbus-layout',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FileExplorerComponent, NoteViewerComponent, AppBottomNavigationComponent, AppSidebarDrawerComponent, AppTocOverlayComponent, SwipeNavDirective, NotesListComponent, NimbusSidebarComponent, QuickLinksComponent, ScrollableOverlayDirective, MarkdownPlaygroundComponent, TestsPanelComponent, TestExcalidrawPageComponent, ParametersPage, AboutPanelComponent, NoteInfoModalComponent, InPageSearchOverlayComponent],
|
||||
imports: [CommonModule, FileExplorerComponent, NoteViewerComponent, AppBottomNavigationComponent, AppSidebarDrawerComponent, AppTocOverlayComponent, SwipeNavDirective, PaginatedNotesListComponent, NimbusSidebarComponent, QuickLinksComponent, ScrollableOverlayDirective, MarkdownPlaygroundComponent, TestsPanelComponent, TestExcalidrawPageComponent, ParametersPage, AboutPanelComponent, NoteInfoModalComponent, InPageSearchOverlayComponent],
|
||||
template: `
|
||||
<div class="relative h-screen flex flex-col bg-card dark:bg-main text-main dark:text-gray-100" [style.--sidebar-width.px]="isSidebarOpen ? leftSidebarWidth : 64">
|
||||
|
||||
@@ -170,8 +170,7 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
|
||||
<!-- Center List -->
|
||||
<section class="border-r border-border dark:border-gray-800 overflow-hidden" [style.width.px]="centerPanelWidth">
|
||||
<div class="h-full flex flex-col">
|
||||
<app-notes-list class="flex-1"
|
||||
[notes]="vault.allNotes()"
|
||||
<app-paginated-notes-list class="flex-1"
|
||||
[folderFilter]="folderFilter"
|
||||
[tagFilter]="tagFilter"
|
||||
[quickLinkFilter]="quickLinkFilter"
|
||||
@@ -181,8 +180,6 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
|
||||
(openNote)="onOpenNote($event)"
|
||||
(queryChange)="onQueryChange($event)"
|
||||
(clearQuickLinkFilter)="onClearQuickLinkFilter()"
|
||||
(noteCreated)="onNoteCreated($event)"
|
||||
(noteCreatedAndSelected)="onNoteCreatedAndSelected($event)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -259,7 +256,17 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
|
||||
<app-file-explorer [nodes]="effectiveFileTree" [selectedNoteId]="selectedNoteId" [foldersOnly]="true" [quickLinkFilter]="quickLinkFilter" (folderSelected)="onFolderSelected($event)" (fileSelected)="onOpenNote($event)"></app-file-explorer>
|
||||
</div>
|
||||
<div [hidden]="mobileNav.activeTab() !== 'list'" class="h-full overflow-y-auto" appScrollableOverlay>
|
||||
<app-notes-list [notes]="vault.allNotes()" [folderFilter]="folderFilter" [tagFilter]="tagFilter" [quickLinkFilter]="quickLinkFilter" [kindFilter]="urlState.activeKind()" [query]="listQuery" [selectedId]="selectedNoteId" (queryChange)="onQueryChange($event)" (openNote)="onOpenNote($event)" (clearQuickLinkFilter)="onClearQuickLinkFilter()" (noteCreated)="onNoteCreated($event)" (noteCreatedAndSelected)="onNoteCreatedAndSelected($event)"></app-notes-list>
|
||||
<app-paginated-notes-list
|
||||
[folderFilter]="folderFilter"
|
||||
[tagFilter]="tagFilter"
|
||||
[quickLinkFilter]="quickLinkFilter"
|
||||
[kindFilter]="urlState.activeKind()"
|
||||
[query]="listQuery"
|
||||
[selectedId]="selectedNoteId"
|
||||
(queryChange)="onQueryChange($event)"
|
||||
(openNote)="onOpenNote($event)"
|
||||
(clearQuickLinkFilter)="onClearQuickLinkFilter()"
|
||||
></app-paginated-notes-list>
|
||||
</div>
|
||||
<div [hidden]="mobileNav.activeTab() !== 'page'" class="note-content-area h-full overflow-y-auto" appScrollableOverlay>
|
||||
<app-markdown-playground *ngIf="activeView === 'markdown-playground'"></app-markdown-playground>
|
||||
@@ -292,7 +299,17 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
|
||||
|
||||
@if (mobileNav.activeTab() === 'list') {
|
||||
<div class="h-full flex flex-col overflow-hidden animate-fadeIn">
|
||||
<app-notes-list class="flex-1" [notes]="vault.allNotes()" [folderFilter]="folderFilter" [tagFilter]="tagFilter" [quickLinkFilter]="quickLinkFilter" [kindFilter]="urlState.activeKind()" [query]="listQuery" [selectedId]="selectedNoteId" (queryChange)="onQueryChange($event)" (openNote)="onNoteSelectedMobile($event)" (clearQuickLinkFilter)="onClearQuickLinkFilter()"></app-notes-list>
|
||||
<app-paginated-notes-list class="flex-1"
|
||||
[folderFilter]="folderFilter"
|
||||
[tagFilter]="tagFilter"
|
||||
[quickLinkFilter]="quickLinkFilter"
|
||||
[kindFilter]="urlState.activeKind()"
|
||||
[query]="listQuery"
|
||||
[selectedId]="selectedNoteId"
|
||||
(queryChange)="onQueryChange($event)"
|
||||
(openNote)="onNoteSelectedMobile($event)"
|
||||
(clearQuickLinkFilter)="onClearQuickLinkFilter()"
|
||||
></app-paginated-notes-list>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user