feat: add folder management endpoints and CORS support

This commit is contained in:
2025-10-24 08:02:40 -04:00
parent 7e20098d14
commit 08a2d05dad
39 changed files with 5267 additions and 132 deletions
@@ -0,0 +1,377 @@
import { Component, EventEmitter, Output, computed, signal, effect, inject, ChangeDetectionStrategy } from '@angular/core';
import { input } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { Note } from '../../../types';
import { ScrollableOverlayDirective } from '../../shared/overlay-scrollbar/scrollable-overlay.directive';
import { TagFilterStore } from '../../core/stores/tag-filter.store';
import { NotesListStateService, SortBy, ViewMode } from '../../services/notes-list-state.service';
import { NoteCreationService } from '../../services/note-creation.service';
@Component({
selector: 'app-notes-list',
standalone: true,
imports: [CommonModule, ScrollableOverlayDirective],
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>
</div>
<!-- Path Indicator with Sort and View Mode Menus -->
<div *ngIf="folderFilter()" class="flex items-center justify-between gap-2 px-2 py-1.5 bg-surface1/50 dark:bg-card/50 rounded text-xs">
<div class="flex items-center gap-1.5 min-w-0">
<svg class="h-4 w-4 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
<span class="truncate font-medium">{{ getFolderDisplayName() }}</span>
</div>
<div class="flex items-center gap-1 flex-shrink-0">
<!-- Sort Dropdown -->
<div class="relative" [class.open]="sortMenuOpen()">
<button type="button"
(click)="toggleSortMenu()"
class="inline-flex items-center gap-1 px-2 py-1 rounded hover:bg-surface2/50 dark:hover:bg-surface2/50 transition-colors"
title="Trier par">
<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="M3 6h18"/><path d="M7 12h10"/><path d="M10 18h4"/></svg>
</button>
<div *ngIf="sortMenuOpen()" class="absolute top-full right-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 sort of sortOptions"
(click)="setSortBy(sort)"
[class.bg-surface1]="state.sortBy() === sort"
class="w-full text-left px-3 py-2 text-xs hover:bg-surface1 dark:hover:bg-surface2 transition-colors">
{{ getSortLabel(sort) }}
</button>
</div>
</div>
<!-- View Mode Dropdown -->
<div class="relative" [class.open]="viewModeMenuOpen()">
<button type="button"
(click)="toggleViewModeMenu()"
class="inline-flex items-center gap-1 px-2 py-1 rounded hover:bg-surface2/50 dark:hover:bg-surface2/50 transition-colors"
title="Mode d'affichage">
<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"><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>
<div *ngIf="viewModeMenuOpen()" class="absolute top-full right-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 mode of viewModes"
(click)="setViewMode(mode)"
[class.bg-surface1]="state.viewMode() === mode"
class="w-full text-left px-3 py-2 text-xs hover:bg-surface1 dark:hover:bg-surface2 transition-colors">
{{ getViewModeLabel(mode) }}
</button>
</div>
</div>
</div>
</div>
<!-- Search and New Note -->
<div class="flex gap-2 items-center">
<input type="text"
[value]="query()"
(input)="onQuery($any($event.target).value)"
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"
(click)="openNewNoteDialog()"
class="inline-flex items-center gap-1.5 px-3 py-2 rounded bg-primary hover:bg-primary/90 text-primary-foreground text-sm font-medium transition-colors"
title="Créer une nouvelle note">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<span class="hidden sm:inline">Nouvelle note</span>
</button>
</div>
<!-- Request Status Indicator -->
<div *ngIf="state.lastRequestStats() as stats" class="flex items-center justify-between px-2 py-1 text-xs text-muted">
<div class="flex items-center gap-1.5">
<span *ngIf="stats.success" class="inline-flex items-center gap-1 text-green-600 dark:text-green-400">
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="currentColor"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/></svg>
{{ stats.duration }}ms
</span>
<span *ngIf="!stats.success" class="inline-flex items-center gap-1 text-red-600 dark:text-red-400">
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>
{{ stats.duration }}ms
</span>
</div>
</div>
</div>
<!-- List Container -->
<div class="flex-1 min-h-0 overflow-y-auto list-scroll" appScrollableOverlay>
<ul class="divide-y divide-gray-100 dark:divide-gray-800">
<li *ngFor="let n of filtered()"
[ngClass]="getListItemClasses()"
class="hover:bg-surface1 dark:hover:bg-card cursor-pointer transition-colors"
(click)="openNote.emit(n.id)">
<!-- Compact View -->
<div *ngIf="state.viewMode() === 'compact'" class="px-3 py-1.5">
<div class="text-xs font-semibold truncate">{{ n.title }}</div>
</div>
<!-- Comfortable View (default) -->
<div *ngIf="state.viewMode() === 'comfortable'" class="p-3">
<div class="text-sm font-semibold truncate">{{ n.title }}</div>
<div class="text-xs text-muted truncate">{{ n.filePath }}</div>
</div>
<!-- Detailed View -->
<div *ngIf="state.viewMode() === 'detailed'" class="p-3 space-y-1.5">
<div class="text-sm font-semibold truncate">{{ n.title }}</div>
<div class="text-xs text-muted truncate">{{ n.filePath }}</div>
<div class="text-xs text-muted/70">
<span *ngIf="n.frontmatter?.status">Status: {{ n.frontmatter.status }}</span>
<span *ngIf="n.mtime" class="ml-2">{{ formatDate(n.mtime) }}</span>
</div>
</div>
</li>
</ul>
</div>
</div>
`,
styles: [`
:host {
display: block;
height: 100%;
min-height: 0;
}
.list-scroll {
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
scrollbar-gutter: stable both-edges;
max-height: 100%;
contain: content;
}
.relative.open {
z-index: 20;
}
`]
})
export class NotesListComponent {
notes = input<Note[]>([]);
folderFilter = input<string | null>(null);
query = input<string>('');
tagFilter = input<string | null>(null);
quickLinkFilter = input<'favoris' | 'publish' | 'draft' | 'template' | 'task' | 'private' | 'archive' | null>(null);
@Output() openNote = new EventEmitter<string>();
@Output() queryChange = new EventEmitter<string>();
@Output() clearQuickLinkFilter = new EventEmitter<void>();
@Output() noteCreated = new EventEmitter<string>();
private store = inject(TagFilterStore);
readonly state = inject(NotesListStateService);
private noteCreationService = inject(NoteCreationService);
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);
});
private syncTagFromStore = effect(() => {
const inputTag = this.tagFilter();
if (inputTag !== null && inputTag !== undefined) {
this.activeTag.set(inputTag || null);
return;
}
this.activeTag.set(this.store.get());
});
filtered = computed(() => {
const q = (this.q() || '').toLowerCase().trim();
const folder = (this.folderFilter() || '').toLowerCase().replace(/^\/+|\/+$/g, '');
const tag = (this.activeTag() || '').toLowerCase();
const quickLink = this.quickLinkFilter();
const sortBy = this.state.sortBy();
let list = this.notes();
if (folder !== '.trash') {
list = list.filter(n => {
const filePath = (n.filePath || n.originalPath || '').toLowerCase().replace(/\\/g, '/');
return !filePath.startsWith('.trash/') && !filePath.includes('/.trash/');
});
}
if (folder) {
if (folder === '.trash') {
list = list.filter(n => {
const filePath = (n.filePath || n.originalPath || '').toLowerCase().replace(/\\/g, '/');
return filePath.startsWith('.trash/') || filePath.includes('/.trash/');
});
} else {
list = list.filter(n => {
const originalPath = (n.originalPath || '').toLowerCase().replace(/^\/+|\/+$/g, '');
return originalPath === folder || originalPath.startsWith(folder + '/');
});
}
}
if (tag) {
list = list.filter(n => Array.isArray(n.tags) && n.tags.some(t => (t || '').toLowerCase() === tag));
}
if (quickLink) {
list = list.filter(n => {
const frontmatter = n.frontmatter || {};
return frontmatter[quickLink] === true;
});
}
if (q) {
list = list.filter(n => {
const title = (n.title || '').toLowerCase();
const filePath = (n.filePath || '').toLowerCase();
return title.includes(q) || filePath.includes(q);
});
}
const parseDate = (s?: string) => (s ? Date.parse(s) : 0) || 0;
return [...list].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:
return (b.mtime || parseDate(b.updatedAt) || parseDate(b.createdAt) || 0) -
(a.mtime || parseDate(a.updatedAt) || parseDate(a.createdAt) || 0);
}
});
});
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);
}
}
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];
}
getFolderDisplayName(): string {
const folder = this.folderFilter() || '';
if (!folder) return '';
const parts = folder.split('/').filter(p => p);
return parts[parts.length - 1] || folder;
}
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';
}
formatDate(timestamp: number): string {
const date = new Date(timestamp);
return date.toLocaleDateString('fr-FR', { month: 'short', day: 'numeric' });
}
openNewNoteDialog(): void {
const folderPath = this.folderFilter() || '/';
const baseName = 'Nouvelle note';
const existingTitles = this.notes().map(n => n.title);
let fileName = baseName;
let counter = 1;
while (existingTitles.includes(fileName)) {
fileName = `${baseName} ${counter}`;
counter++;
}
this.noteCreationService.createNote(fileName, folderPath)
.then(response => {
this.noteCreated.emit(response.id);
this.openNote.emit(response.id);
})
.catch(error => {
console.error('Failed to create note:', error);
this.state.setRequestStats(false, 0);
});
}
}
+252 -27
View File
@@ -1,16 +1,20 @@
import { Component, EventEmitter, Output, computed, signal, effect, inject } from '@angular/core';
import { Component, EventEmitter, Output, computed, signal, effect, inject, ChangeDetectionStrategy } from '@angular/core';
import { input } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { Note } from '../../../types';
import { ScrollableOverlayDirective } from '../../shared/overlay-scrollbar/scrollable-overlay.directive';
import { TagFilterStore } from '../../core/stores/tag-filter.store';
import { NotesListStateService, SortBy, ViewMode } from '../../services/notes-list-state.service';
import { NoteCreationService } from '../../services/note-creation.service';
@Component({
selector: 'app-notes-list',
standalone: true,
imports: [CommonModule, ScrollableOverlayDirective],
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">
@@ -29,17 +33,125 @@ import { TagFilterStore } from '../../core/stores/tag-filter.store';
<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>
<input type="text"
[value]="query()"
(input)="onQuery($any($event.target).value)"
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" />
<!-- Path Indicator with Sort and View Mode Menus -->
<div *ngIf="folderFilter()" class="flex items-center justify-between gap-2 px-2 py-1.5 bg-surface1/50 dark:bg-card/50 rounded text-xs">
<div class="flex items-center gap-1.5 min-w-0">
<svg class="h-4 w-4 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
<span class="truncate font-medium">{{ getFolderDisplayName() }}</span>
</div>
</div>
<!-- Search and New Note -->
<div class="flex gap-2 items-center">
<input type="text"
[value]="query()"
(input)="onQuery($any($event.target).value)"
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"
(click)="openNewNoteDialog()"
class="inline-flex items-center gap-1.5 px-3 py-2 rounded bg-primary hover:bg-primary/90 text-primary-foreground text-sm font-medium transition-colors"
title="Créer une nouvelle note">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<span class="hidden sm:inline">Nouvelle note</span>
</button>
</div>
<!-- Action Buttons (always visible) -->
<div class="action-buttons flex justify-between items-center">
<div class="flex items-center gap-2">
<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 Menu -->
<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 sort of sortOptions"
(click)="setSortBy(sort)"
[class.bg-surface1]="state.sortBy() === sort"
class="w-full text-left px-3 py-2 text-xs hover:bg-surface1 dark:hover:bg-surface2 transition-colors">
{{ getSortLabel(sort) }}
</button>
</div>
<!-- View Mode Dropdown Menu -->
<div *ngIf="viewModeMenuOpen()" class="absolute top-full left-8 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 mode of viewModes"
(click)="setViewMode(mode)"
[class.bg-surface1]="state.viewMode() === mode"
class="w-full text-left px-3 py-2 text-xs hover:bg-surface1 dark:hover:bg-surface2 transition-colors">
{{ getViewModeLabel(mode) }}
</button>
</div>
</div>
<!-- Request Status Indicator (right side) -->
<div *ngIf="state.lastRequestStats() as stats" class="flex items-center text-xs text-muted">
<div class="flex items-center gap-1.5">
<span *ngIf="stats.success" class="inline-flex items-center gap-1 text-green-600 dark:text-green-400">
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="currentColor"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/></svg>
{{ stats.duration }}ms
</span>
<span *ngIf="!stats.success" class="inline-flex items-center gap-1 text-red-600 dark:text-red-400">
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>
{{ stats.duration }}ms
</span>
</div>
</div>
</div>
</div>
<!-- List Container -->
<div class="flex-1 min-h-0 overflow-y-auto list-scroll" appScrollableOverlay>
<!-- Empty state when a folder is selected but no notes match -->
<div *ngIf="(folderFilter() && filtered().length === 0)" class="h-full min-h-[240px] flex items-center justify-center text-center p-6">
<div>
<div class="text-lg font-semibold text-[var(--fg)]">It’s empty here</div>
<div class="text-sm text-muted mt-1">You can create your page</div>
<button type="button" (click)="openNewNoteDialog()" class="mt-3 inline-flex items-center gap-1.5 px-3 py-2 rounded bg-primary hover:bg-primary/90 text-primary-foreground text-sm font-medium transition-colors">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Create page in this folder
</button>
</div>
</div>
<ul class="divide-y divide-gray-100 dark:divide-gray-800">
<li *ngFor="let n of filtered()" class="p-3 hover:bg-surface1 dark:hover:bg-card cursor-pointer" (click)="openNote.emit(n.id)">
<div class="text-sm font-semibold truncate">{{ n.title }}</div>
<div class="text-xs text-muted truncate">{{ n.filePath }}</div>
<li *ngFor="let n of filtered()"
[ngClass]="getListItemClasses()"
class="hover:bg-surface1 dark:hover:bg-card cursor-pointer transition-colors"
(click)="openNote.emit(n.id)">
<!-- Compact View -->
<div *ngIf="state.viewMode() === 'compact'" class="px-3 py-1.5">
<div class="text-xs font-semibold truncate">{{ n.title }}</div>
</div>
<!-- Comfortable View (default) -->
<div *ngIf="state.viewMode() === 'comfortable'" class="p-3">
<div class="text-sm font-semibold truncate">{{ n.title }}</div>
<div class="text-xs text-muted truncate">{{ n.filePath }}</div>
</div>
<!-- Detailed View -->
<div *ngIf="state.viewMode() === 'detailed'" class="p-3 space-y-1.5">
<div class="text-sm font-semibold truncate">{{ n.title }}</div>
<div class="text-xs text-muted truncate">{{ n.filePath }}</div>
<div class="text-xs text-muted/70">
<span *ngIf="n.frontmatter?.status">Status: {{ n.frontmatter.status }}</span>
<span *ngIf="n.mtime" class="ml-2">{{ formatDate(n.mtime) }}</span>
</div>
</div>
</li>
</ul>
</div>
@@ -49,23 +161,36 @@ import { TagFilterStore } from '../../core/stores/tag-filter.store';
:host {
display: block;
height: 100%;
min-height: 0; /* critical for nested flex scrolling */
min-height: 0;
}
/* Smooth, bounded vertical scrolling only on the list area */
.list-scroll {
overscroll-behavior: contain; /* prevent parent scroll chaining */
-webkit-overflow-scrolling: touch; /* momentum scrolling on iOS */
scroll-behavior: smooth; /* smooth programmatic scrolls */
scrollbar-gutter: stable both-edges; /* avoid layout shift when scrollbar shows */
max-height: 100%; /* cap to available space within the central section */
contain: content; /* small perf win for large lists */
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
scrollbar-gutter: stable both-edges;
max-height: 100%;
contain: content;
}
.relative.open {
z-index: 20;
}
/* Action buttons container */
.action-buttons {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 0.5rem;
}
`]
})
export class NotesListComponent {
notes = input<Note[]>([]);
folderFilter = input<string | null>(null); // like "folder/subfolder"
folderFilter = input<string | null>(null);
query = input<string>('');
tagFilter = input<string | null>(null);
quickLinkFilter = input<'favoris' | 'publish' | 'draft' | 'template' | 'task' | 'private' | 'archive' | null>(null);
@@ -73,15 +198,30 @@ export class NotesListComponent {
@Output() openNote = new EventEmitter<string>();
@Output() queryChange = new EventEmitter<string>();
@Output() clearQuickLinkFilter = new EventEmitter<void>();
@Output() noteCreated = new EventEmitter<string>();
private store = inject(TagFilterStore);
readonly state = inject(NotesListStateService);
private noteCreationService = inject(NoteCreationService);
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);
});
private syncTagFromStore = effect(() => {
// Prefer explicit input; otherwise, take store value
const inputTag = this.tagFilter();
if (inputTag !== null && inputTag !== undefined) {
this.activeTag.set(inputTag || null);
@@ -95,9 +235,9 @@ export class NotesListComponent {
const folder = (this.folderFilter() || '').toLowerCase().replace(/^\/+|\/+$/g, '');
const tag = (this.activeTag() || '').toLowerCase();
const quickLink = this.quickLinkFilter();
const sortBy = this.state.sortBy();
let list = this.notes();
// Exclude trash notes by default unless specifically viewing trash
if (folder !== '.trash') {
list = list.filter(n => {
const filePath = (n.filePath || n.originalPath || '').toLowerCase().replace(/\\/g, '/');
@@ -107,7 +247,6 @@ export class NotesListComponent {
if (folder) {
if (folder === '.trash') {
// All files anywhere under .trash (including subfolders)
list = list.filter(n => {
const filePath = (n.filePath || n.originalPath || '').toLowerCase().replace(/\\/g, '/');
return filePath.startsWith('.trash/') || filePath.includes('/.trash/');
@@ -124,7 +263,6 @@ export class NotesListComponent {
list = list.filter(n => Array.isArray(n.tags) && n.tags.some(t => (t || '').toLowerCase() === tag));
}
// Apply Quick Link filter (favoris, template, task)
if (quickLink) {
list = list.filter(n => {
const frontmatter = n.frontmatter || {};
@@ -132,7 +270,6 @@ export class NotesListComponent {
});
}
// Apply query if present
if (q) {
list = list.filter(n => {
const title = (n.title || '').toLowerCase();
@@ -141,10 +278,19 @@ export class NotesListComponent {
});
}
// Sort by most recent first (mtime desc; fallback updatedAt/createdAt)
const parseDate = (s?: string) => (s ? Date.parse(s) : 0) || 0;
const score = (n: Note) => n.mtime || parseDate(n.updatedAt) || parseDate(n.createdAt) || 0;
return [...list].sort((a, b) => (score(b) - score(a)));
return [...list].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:
return (b.mtime || parseDate(b.updatedAt) || parseDate(b.createdAt) || 0) -
(a.mtime || parseDate(a.updatedAt) || parseDate(a.createdAt) || 0);
}
});
});
getQuickLinkDisplay(quickLink: string): { icon: string; name: string } | null {
@@ -166,10 +312,89 @@ export class NotesListComponent {
}
clearTagFilter(): void {
// Clear both local input state and store
this.activeTag.set(null);
if (this.tagFilter() == null) {
this.store.set(null);
}
}
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];
}
getFolderDisplayName(): string {
const folder = this.folderFilter() || '';
if (!folder) return '';
const parts = folder.split('/').filter(p => p);
return parts[parts.length - 1] || folder;
}
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';
}
formatDate(timestamp: number): string {
const date = new Date(timestamp);
return date.toLocaleDateString('fr-FR', { month: 'short', day: 'numeric' });
}
openNewNoteDialog(): void {
const folderPath = this.folderFilter() || '/';
const baseName = 'Nouvelle note';
const existingTitles = this.notes().map(n => n.title);
let fileName = baseName;
let counter = 1;
while (existingTitles.includes(fileName)) {
fileName = `${baseName} ${counter}`;
counter++;
}
this.noteCreationService.createNote(fileName, folderPath)
.then(response => {
this.noteCreated.emit(response.id);
this.openNote.emit(response.id);
})
.catch(error => {
console.error('Failed to create note:', error);
this.state.setRequestStats(false, 0);
});
}
}
@@ -0,0 +1 @@
// Backup created before enhancement
@@ -1,4 +1,4 @@
import { AfterViewInit, Component, ElementRef, Input, OnDestroy, Output, EventEmitter, ViewContainerRef, inject } from '@angular/core';
import { AfterViewInit, Component, ElementRef, Input, OnDestroy, Output, EventEmitter, ViewContainerRef, inject, OnChanges, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { debounceTime, Subject } from 'rxjs';
import { splitPathKeepFilename } from '../../../../shared/utils/path';
@@ -16,7 +16,7 @@ import { VaultService } from '../../../../../services/vault.service';
templateUrl: './note-header.component.html',
styleUrls: ['./note-header.component.scss']
})
export class NoteHeaderComponent implements AfterViewInit, OnDestroy {
export class NoteHeaderComponent implements AfterViewInit, OnDestroy, OnChanges {
@Input() fullPath = '';
@Input() noteId = '';
@Input() tags: string[] = [];
@@ -42,6 +42,16 @@ export class NoteHeaderComponent implements AfterViewInit, OnDestroy {
constructor(private host: ElementRef<HTMLElement>) {}
ngOnChanges(changes: SimpleChanges): void {
if (changes['fullPath']) {
this.pathParts = splitPathKeepFilename(this.fullPath);
// Also update the path display if component is already initialized
if (this.ro) {
this.fitPath();
}
}
}
ngAfterViewInit(): void {
this.pathParts = splitPathKeepFilename(this.fullPath);
+141
View File
@@ -0,0 +1,141 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { NoteFrontmatter } from '../../types';
export interface CreateNoteRequest {
fileName: string;
folderPath: string;
frontmatter: NoteFrontmatter;
content?: string;
}
export interface CreateNoteResponse {
id: string;
fileName: string;
filePath: string;
success: boolean;
}
@Injectable({
providedIn: 'root'
})
export class NoteCreationService {
private readonly API_ENDPOINT = '/api/vault/notes';
constructor(private http: HttpClient) {}
/**
* Create a new note with default frontmatter
*/
async createNote(
fileName: string,
folderPath: string,
author: string = 'Bruno Charest',
additionalFrontmatter?: Partial<NoteFrontmatter>
): Promise<CreateNoteResponse> {
const now = new Date().toISOString();
// Build frontmatter with defaults
const frontmatter: NoteFrontmatter = {
titre: fileName.replace(/\.md$/, ''),
auteur: author,
creation_date: now,
modification_date: now,
status: 'en-cours',
publish: false,
favoris: false,
template: false,
task: false,
archive: false,
draft: false,
private: false,
...additionalFrontmatter
};
const request: CreateNoteRequest = {
fileName: fileName.endsWith('.md') ? fileName : `${fileName}.md`,
folderPath: folderPath || '/',
frontmatter,
content: ''
};
try {
const response = await firstValueFrom(
this.http.post<CreateNoteResponse>(this.API_ENDPOINT, request)
);
return response;
} catch (error) {
console.error('Error creating note:', error);
throw error;
}
}
/**
* Generate a unique filename based on a base name
*/
generateUniqueFileName(baseName: string, existingFiles: string[]): string {
const cleanName = baseName.replace(/\.md$/, '').trim();
let fileName = `${cleanName}.md`;
let counter = 1;
while (existingFiles.some(f => f.toLowerCase() === fileName.toLowerCase())) {
fileName = `${cleanName} ${counter}.md`;
counter++;
}
return fileName;
}
/**
* Format frontmatter to YAML string
*/
formatFrontmatterYAML(frontmatter: NoteFrontmatter): string {
const lines: string[] = ['---'];
// Add fields in a specific order for readability
const fieldOrder: (keyof NoteFrontmatter)[] = [
'titre',
'auteur',
'creation_date',
'modification_date',
'status',
'publish',
'favoris',
'template',
'task',
'archive',
'draft',
'private'
];
for (const field of fieldOrder) {
if (field in frontmatter) {
const value = frontmatter[field];
if (typeof value === 'string') {
lines.push(`${field}: "${value}"`);
} else if (typeof value === 'boolean') {
lines.push(`${field}: ${value}`);
} else if (Array.isArray(value)) {
lines.push(`${field}: [${value.map(v => `"${v}"`).join(', ')}]`);
}
}
}
// Add any additional fields
for (const [key, value] of Object.entries(frontmatter)) {
if (!fieldOrder.includes(key as keyof NoteFrontmatter)) {
if (typeof value === 'string') {
lines.push(`${key}: "${value}"`);
} else if (typeof value === 'boolean') {
lines.push(`${key}: ${value}`);
} else if (Array.isArray(value)) {
lines.push(`${key}: [${value.map(v => `"${v}"`).join(', ')}]`);
}
}
}
lines.push('---');
return lines.join('\n');
}
}
@@ -0,0 +1,89 @@
import { Injectable, signal, computed } from '@angular/core';
export type SortBy = 'title' | 'created' | 'updated';
export type ViewMode = 'compact' | 'comfortable' | 'detailed';
export interface RequestStats {
success: boolean;
duration: number; // in ms
timestamp: number;
}
@Injectable({
providedIn: 'root'
})
export class NotesListStateService {
// State signals
private sortBySignal = signal<SortBy>('updated');
private viewModeSignal = signal<ViewMode>('comfortable');
private lastRequestStatsSignal = signal<RequestStats | null>(null);
private isLoadingSignal = signal<boolean>(false);
// Computed signals
readonly sortBy = computed(() => this.sortBySignal());
readonly viewMode = computed(() => this.viewModeSignal());
readonly lastRequestStats = computed(() => this.lastRequestStatsSignal());
readonly isLoading = computed(() => this.isLoadingSignal());
// Getters for direct access
getSortBy(): SortBy {
return this.sortBySignal();
}
getViewMode(): ViewMode {
return this.viewModeSignal();
}
getLastRequestStats(): RequestStats | null {
return this.lastRequestStatsSignal();
}
// Setters
setSortBy(sort: SortBy): void {
this.sortBySignal.set(sort);
}
setViewMode(mode: ViewMode): void {
this.viewModeSignal.set(mode);
}
setRequestStats(success: boolean, duration: number): void {
this.lastRequestStatsSignal.set({
success,
duration,
timestamp: Date.now()
});
}
setLoading(loading: boolean): void {
this.isLoadingSignal.set(loading);
}
// Helper to get display text for sort
getSortLabel(): string {
const labels: Record<SortBy, string> = {
'title': 'Titre',
'created': 'Date création',
'updated': 'Date modification'
};
return labels[this.sortBySignal()];
}
// Helper to get display text for view mode
getViewModeLabel(): string {
const labels: Record<ViewMode, string> = {
'compact': 'Compact',
'comfortable': 'Confortable',
'detailed': 'Détaillé'
};
return labels[this.viewModeSignal()];
}
// Reset to defaults
reset(): void {
this.sortBySignal.set('updated');
this.viewModeSignal.set('comfortable');
this.lastRequestStatsSignal.set(null);
this.isLoadingSignal.set(false);
}
}