feat: add folder management endpoints and CORS support
This commit is contained in:
@@ -662,11 +662,13 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
this.themeService.initFromStorage();
|
||||
|
||||
// Initialize vault with metadata-first approach (Phase 1)
|
||||
this.vaultService.initializeVault().then(() => {
|
||||
this.vaultService.initializeVault().then(async () => {
|
||||
console.log(`[AppComponent] Vault initialized with ${this.vaultService.getNotesCount()} notes`);
|
||||
this.logService.log('VAULT_INITIALIZED', {
|
||||
notesCount: this.vaultService.getNotesCount()
|
||||
});
|
||||
// Validate folders against filesystem to purge any ghost entries on startup
|
||||
try { await this.vaultService.validateFolderTree(); } catch {}
|
||||
}).catch(error => {
|
||||
console.error('[AppComponent] Failed to initialize vault:', error);
|
||||
this.logService.log('VAULT_INIT_FAILED', {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
HostListener,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
Output,
|
||||
Renderer2,
|
||||
SimpleChanges,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
type CtxAction =
|
||||
| 'create-subfolder'
|
||||
| 'rename'
|
||||
| 'duplicate'
|
||||
| 'create-page'
|
||||
| 'copy-link'
|
||||
| 'delete-folder'
|
||||
| 'delete-all';
|
||||
|
||||
@Component({
|
||||
selector: 'app-context-menu',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [`
|
||||
:host { position: fixed; inset: 0; pointer-events: none; }
|
||||
.ctx {
|
||||
pointer-events: auto;
|
||||
min-width: 14rem;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.25rem 0.25rem;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,.25);
|
||||
backdrop-filter: blur(6px);
|
||||
animation: fadeIn .12s ease-out;
|
||||
transform-origin: top left;
|
||||
user-select: none;
|
||||
/* Theme-aware background and border */
|
||||
background: var(--card, #ffffff);
|
||||
border: 1px solid var(--border, #e5e7eb);
|
||||
color: var(--fg, #111827);
|
||||
}
|
||||
.item {
|
||||
display: block; width: 100%;
|
||||
text-align: left;
|
||||
padding: .5rem .75rem;
|
||||
border-radius: .5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
transition: background-color 0.08s ease;
|
||||
color: var(--text-main, #111827);
|
||||
}
|
||||
.item:hover {
|
||||
background: color-mix(in oklab, var(--surface-1, #f8fafc) 90%, black 0%);
|
||||
}
|
||||
.item:active {
|
||||
background: color-mix(in oklab, var(--surface-2, #eef2f7) 85%, black 0%);
|
||||
}
|
||||
.item.danger { color: var(--danger, #ef4444); }
|
||||
.item.warning { color: var(--warning, #f59e0b); }
|
||||
.sep {
|
||||
border-top: 1px solid var(--border, #e5e7eb);
|
||||
margin:.25rem 0;
|
||||
}
|
||||
.row {
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-around;
|
||||
gap:.5rem;
|
||||
padding:.5rem .5rem;
|
||||
}
|
||||
.dot {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
transition: transform .08s ease;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
.dot:hover {
|
||||
transform: scale(1.15);
|
||||
outline: 2px solid color-mix(in oklab, var(--canvas, #ffffff) 70%, var(--fg, #111827) 15%);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@keyframes fadeIn { from { opacity:0; transform: scale(.95);} to { opacity:1; transform: scale(1);} }
|
||||
`],
|
||||
template: `
|
||||
<ng-container *ngIf="visible">
|
||||
<!-- Backdrop pour capter les clics extérieurs -->
|
||||
<div class="fixed inset-0" (click)="close()" aria-hidden="true"></div>
|
||||
|
||||
<!-- Menu -->
|
||||
<div
|
||||
#menu
|
||||
class="ctx"
|
||||
[ngStyle]="{ left: left + 'px', top: top + 'px', position:'fixed' }"
|
||||
role="menu"
|
||||
(contextmenu)="$event.preventDefault()"
|
||||
>
|
||||
<button class="item" (click)="emitAction('create-subfolder')">
|
||||
<span class="inline-block mr-2">📁</span>Create subfolder
|
||||
</button>
|
||||
<button class="item" (click)="emitAction('rename')">
|
||||
<span class="inline-block mr-2">✏️</span>Rename
|
||||
</button>
|
||||
<button class="item" (click)="emitAction('duplicate')">
|
||||
<span class="inline-block mr-2">📋</span>Duplicate
|
||||
</button>
|
||||
<button class="item" (click)="emitAction('create-page')">
|
||||
<span class="inline-block mr-2">📄</span>Create new page
|
||||
</button>
|
||||
<button class="item" (click)="emitAction('copy-link')">
|
||||
<span class="inline-block mr-2">🔗</span>Copy internal link
|
||||
</button>
|
||||
|
||||
<div class="sep"></div>
|
||||
|
||||
<button class="item danger" (click)="emitAction('delete-folder')">
|
||||
<span class="inline-block mr-2">🗑️</span>Delete folder
|
||||
</button>
|
||||
<button class="item warning" (click)="emitAction('delete-all')">
|
||||
<span class="inline-block mr-2">⚠️</span>Delete all pages in folder
|
||||
</button>
|
||||
|
||||
<div class="sep"></div>
|
||||
|
||||
<div class="row">
|
||||
<div *ngFor="let c of colors"
|
||||
class="dot"
|
||||
[style.background]="c"
|
||||
(click)="emitColor(c)"
|
||||
[attr.aria-label]="'color ' + c"
|
||||
role="button"
|
||||
title="Set folder color"></div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
`,
|
||||
})
|
||||
export class ContextMenuComponent implements OnChanges, OnDestroy {
|
||||
/** Position demandée (pixels viewport) */
|
||||
@Input() x = 0;
|
||||
@Input() y = 0;
|
||||
/** Contrôle d'affichage */
|
||||
@Input() visible = false;
|
||||
|
||||
/** Actions/retours */
|
||||
@Output() action = new EventEmitter<CtxAction>();
|
||||
@Output() color = new EventEmitter<string>();
|
||||
@Output() closed = new EventEmitter<void>();
|
||||
|
||||
/** Palette 8 couleurs */
|
||||
colors = ['#0ea5e9','#3b82f6','#22c55e','#eab308','#f97316','#ef4444','#a855f7','#64748b'];
|
||||
|
||||
/** Position corrigée (anti overflow) */
|
||||
left = 0;
|
||||
top = 0;
|
||||
|
||||
@ViewChild('menu') menuRef?: ElementRef<HTMLElement>;
|
||||
|
||||
private removeResize?: () => void;
|
||||
private removeScroll?: () => void;
|
||||
|
||||
constructor(private r2: Renderer2, private host: ElementRef<HTMLElement>) {
|
||||
// listeners globaux qui ferment le menu
|
||||
this.removeResize = this.r2.listen('window', 'resize', () => this.reposition());
|
||||
this.removeScroll = this.r2.listen('window', 'scroll', () => this.reposition());
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['visible'] && this.visible) {
|
||||
// Laisse le DOM peindre le menu, puis calcule les bornes
|
||||
queueMicrotask(() => this.reposition());
|
||||
}
|
||||
if ((changes['x'] || changes['y']) && this.visible) {
|
||||
queueMicrotask(() => this.reposition());
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.removeResize?.();
|
||||
this.removeScroll?.();
|
||||
}
|
||||
|
||||
/** Ferme le menu */
|
||||
close() {
|
||||
if (!this.visible) return;
|
||||
this.visible = false;
|
||||
this.closed.emit();
|
||||
}
|
||||
|
||||
emitAction(a: CtxAction) {
|
||||
this.action.emit(a);
|
||||
this.close();
|
||||
}
|
||||
|
||||
emitColor(c: string) {
|
||||
this.color.emit(c);
|
||||
this.close();
|
||||
}
|
||||
|
||||
/** Corrige la position si le menu sortirait du viewport */
|
||||
private reposition() {
|
||||
const el = this.menuRef?.nativeElement;
|
||||
if (!el) { this.left = this.x; this.top = this.y; return; }
|
||||
|
||||
const menuRect = el.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
let left = this.x;
|
||||
let top = this.y;
|
||||
|
||||
if (left + menuRect.width > vw - 8) left = Math.max(8, vw - menuRect.width - 8);
|
||||
if (top + menuRect.height > vh - 8) top = Math.max(8, vh - menuRect.height - 8);
|
||||
|
||||
this.left = left;
|
||||
this.top = top;
|
||||
}
|
||||
|
||||
/** Fermer avec ESC */
|
||||
@HostListener('window:keydown', ['$event'])
|
||||
onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') this.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Context Menu Configuration
|
||||
* Defines all available actions and color palette for folder context menu
|
||||
*/
|
||||
|
||||
export type ContextMenuAction =
|
||||
| 'create-subfolder'
|
||||
| 'rename'
|
||||
| 'duplicate'
|
||||
| 'create-page'
|
||||
| 'copy-link'
|
||||
| 'delete-folder'
|
||||
| 'delete-all';
|
||||
|
||||
export interface ContextMenuActionConfig {
|
||||
id: ContextMenuAction;
|
||||
label: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
isDangerous: boolean;
|
||||
requiresConfirmation: boolean;
|
||||
}
|
||||
|
||||
export interface ContextMenuColorConfig {
|
||||
hex: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Available actions in the context menu
|
||||
*/
|
||||
export const CONTEXT_MENU_ACTIONS: ContextMenuActionConfig[] = [
|
||||
{
|
||||
id: 'create-subfolder',
|
||||
label: 'Create subfolder',
|
||||
icon: '📁',
|
||||
description: 'Create a new subfolder inside this folder',
|
||||
isDangerous: false,
|
||||
requiresConfirmation: false,
|
||||
},
|
||||
{
|
||||
id: 'rename',
|
||||
label: 'Rename',
|
||||
icon: '✏️',
|
||||
description: 'Rename this folder',
|
||||
isDangerous: false,
|
||||
requiresConfirmation: false,
|
||||
},
|
||||
{
|
||||
id: 'duplicate',
|
||||
label: 'Duplicate',
|
||||
icon: '📋',
|
||||
description: 'Create a copy of this folder with all its contents',
|
||||
isDangerous: false,
|
||||
requiresConfirmation: false,
|
||||
},
|
||||
{
|
||||
id: 'create-page',
|
||||
label: 'Create new page',
|
||||
icon: '📄',
|
||||
description: 'Create a new markdown page in this folder',
|
||||
isDangerous: false,
|
||||
requiresConfirmation: false,
|
||||
},
|
||||
{
|
||||
id: 'copy-link',
|
||||
label: 'Copy internal link',
|
||||
icon: '🔗',
|
||||
description: 'Copy the internal link to this folder',
|
||||
isDangerous: false,
|
||||
requiresConfirmation: false,
|
||||
},
|
||||
{
|
||||
id: 'delete-folder',
|
||||
label: 'Delete folder',
|
||||
icon: '🗑️',
|
||||
description: 'Delete this folder',
|
||||
isDangerous: true,
|
||||
requiresConfirmation: true,
|
||||
},
|
||||
{
|
||||
id: 'delete-all',
|
||||
label: 'Delete all pages in folder',
|
||||
icon: '⚠️',
|
||||
description: 'Delete all pages in this folder (cannot be undone)',
|
||||
isDangerous: true,
|
||||
requiresConfirmation: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Color palette for folder categorization
|
||||
*/
|
||||
export const CONTEXT_MENU_COLORS: ContextMenuColorConfig[] = [
|
||||
{
|
||||
hex: '#0ea5e9',
|
||||
name: 'Sky Blue',
|
||||
description: 'Default, general purpose',
|
||||
},
|
||||
{
|
||||
hex: '#3b82f6',
|
||||
name: 'Blue',
|
||||
description: 'Important folders',
|
||||
},
|
||||
{
|
||||
hex: '#22c55e',
|
||||
name: 'Green',
|
||||
description: 'Active projects',
|
||||
},
|
||||
{
|
||||
hex: '#eab308',
|
||||
name: 'Yellow',
|
||||
description: 'Attention needed',
|
||||
},
|
||||
{
|
||||
hex: '#f97316',
|
||||
name: 'Orange',
|
||||
description: 'In progress',
|
||||
},
|
||||
{
|
||||
hex: '#ef4444',
|
||||
name: 'Red',
|
||||
description: 'Critical/Urgent',
|
||||
},
|
||||
{
|
||||
hex: '#a855f7',
|
||||
name: 'Purple',
|
||||
description: 'Archive/Special',
|
||||
},
|
||||
{
|
||||
hex: '#64748b',
|
||||
name: 'Gray',
|
||||
description: 'Inactive/Old',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get action configuration by ID
|
||||
*/
|
||||
export function getActionConfig(id: ContextMenuAction): ContextMenuActionConfig | undefined {
|
||||
return CONTEXT_MENU_ACTIONS.find(action => action.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color configuration by hex value
|
||||
*/
|
||||
export function getColorConfig(hex: string): ContextMenuColorConfig | undefined {
|
||||
return CONTEXT_MENU_COLORS.find(color => color.hex === hex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all hex colors for the palette
|
||||
*/
|
||||
export function getAllColors(): string[] {
|
||||
return CONTEXT_MENU_COLORS.map(color => color.hex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an action is dangerous (requires confirmation)
|
||||
*/
|
||||
export function isActionDangerous(id: ContextMenuAction): boolean {
|
||||
const action = getActionConfig(id);
|
||||
return action?.isDangerous ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get confirmation message for an action
|
||||
*/
|
||||
export function getConfirmationMessage(id: ContextMenuAction, folderName: string): string {
|
||||
switch (id) {
|
||||
case 'delete-folder':
|
||||
return `Are you sure you want to delete the folder "${folderName}"?`;
|
||||
case 'delete-all':
|
||||
return `Are you sure you want to delete ALL pages in "${folderName}"? This cannot be undone.`;
|
||||
default:
|
||||
return 'Are you sure?';
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Component, ChangeDetectionStrategy, input, output, inject } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy, input, output, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { VaultNode, VaultFile, VaultFolder } from '../../types';
|
||||
import { VaultService } from '../../services/vault.service';
|
||||
import { NoteCreationService } from '../../app/services/note-creation.service';
|
||||
import { BadgeCountComponent } from '../../app/shared/ui/badge-count.component';
|
||||
import { ContextMenuComponent } from '../context-menu/context-menu.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-file-explorer',
|
||||
@@ -15,12 +18,13 @@ import { BadgeCountComponent } from '../../app/shared/ui/badge-count.component';
|
||||
<div>
|
||||
<div
|
||||
(click)="onFolderClick(folder)"
|
||||
(contextmenu)="openContextMenu($event, folder)"
|
||||
class="flex items-center cursor-pointer px-3 py-2 rounded-lg my-0.5 text-sm hover:bg-slate-500/10 dark:hover:bg-surface2/10 transition"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 transition-transform text-obs-l-text-muted dark:text-obs-d-text-muted" [class.rotate-90]="folder.isOpen" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 transition-transform flex-shrink-0" [class.rotate-90]="folder.isOpen" fill="none" viewBox="0 0 24 24" stroke="currentColor" [style.color]="getFolderColor(folder.path)">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 flex-shrink-0 text-obs-l-text-muted dark:text-obs-d-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" [style.color]="getFolderColor(folder.path)"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path></svg>
|
||||
<span class="font-semibold truncate">{{ folder.name }}</span>
|
||||
<app-badge-count class="ml-auto" [count]="folderCount(folder.path)" color="slate"></app-badge-count>
|
||||
</div>
|
||||
@@ -57,9 +61,55 @@ import { BadgeCountComponent } from '../../app/shared/ui/badge-count.component';
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
<!-- Context Menu -->
|
||||
<app-context-menu
|
||||
[x]="ctxX()"
|
||||
[y]="ctxY()"
|
||||
[visible]="ctxVisible()"
|
||||
(action)="onContextMenuAction($event)"
|
||||
(color)="onContextMenuColor($event)"
|
||||
(closed)="ctxVisible.set(false)">
|
||||
</app-context-menu>
|
||||
|
||||
<!-- Rename Modal -->
|
||||
<div *ngIf="renameModalVisible()" class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
|
||||
<div class="bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg p-6 w-full max-w-md mx-4">
|
||||
<h3 class="text-lg font-semibold text-[var(--fg)] mb-4">Rename Folder</h3>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-[var(--text-main)] mb-2">
|
||||
New folder name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="renameInputValue"
|
||||
class="w-full px-3 py-2 border border-[var(--border)] rounded-md bg-[var(--bg)] text-[var(--fg)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
|
||||
placeholder="Enter new folder name"
|
||||
(keydown.enter)="confirmRename()"
|
||||
#renameInput
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
(click)="cancelRename()"
|
||||
class="px-4 py-2 text-[var(--text-main)] hover:bg-[var(--surface-1)] rounded-md transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
(click)="confirmRename()"
|
||||
class="px-4 py-2 bg-[var(--primary)] text-white rounded-md hover:bg-[var(--brand-700)] transition-colors"
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, BadgeCountComponent],
|
||||
imports: [CommonModule, FormsModule, BadgeCountComponent, ContextMenuComponent],
|
||||
})
|
||||
export class FileExplorerComponent {
|
||||
nodes = input.required<VaultNode[]>();
|
||||
@@ -70,7 +120,22 @@ export class FileExplorerComponent {
|
||||
fileSelected = output<string>();
|
||||
folderSelected = output<string>();
|
||||
|
||||
// Context menu state
|
||||
ctxVisible = signal(false);
|
||||
ctxX = signal(0);
|
||||
ctxY = signal(0);
|
||||
ctxTarget: VaultFolder | null = null;
|
||||
|
||||
// Rename modal state
|
||||
renameModalVisible = signal(false);
|
||||
renameInputValue = '';
|
||||
renameTarget: VaultFolder | null = null;
|
||||
|
||||
// Folder colors storage
|
||||
private folderColors = new Map<string, string>();
|
||||
|
||||
private vaultService = inject(VaultService);
|
||||
private noteCreation = inject(NoteCreationService);
|
||||
|
||||
folderCount(path: string): number {
|
||||
const quickLink = this.quickLinkFilter();
|
||||
@@ -104,6 +169,70 @@ export class FileExplorerComponent {
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// FOLDER COLOR MANAGEMENT
|
||||
// ========================================
|
||||
|
||||
getFolderColor(folderPath: string): string {
|
||||
return this.folderColors.get(folderPath) || 'currentColor';
|
||||
}
|
||||
|
||||
private setFolderColor(folderPath: string, color: string) {
|
||||
this.folderColors.set(folderPath, color);
|
||||
// Persist to localStorage
|
||||
const colors = Object.fromEntries(this.folderColors);
|
||||
localStorage.setItem('folderColors', JSON.stringify(colors));
|
||||
}
|
||||
|
||||
private loadFolderColors() {
|
||||
const stored = localStorage.getItem('folderColors');
|
||||
if (stored) {
|
||||
try {
|
||||
const colors = JSON.parse(stored);
|
||||
this.folderColors = new Map(Object.entries(colors));
|
||||
} catch (e) {
|
||||
console.error('Failed to load folder colors:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private persistFolderColors() {
|
||||
const colors = Object.fromEntries(this.folderColors);
|
||||
localStorage.setItem('folderColors', JSON.stringify(colors));
|
||||
}
|
||||
|
||||
private migrateFolderColors(oldBase: string, newBase: string) {
|
||||
if (!oldBase || !newBase || oldBase === newBase) return;
|
||||
const updates: Array<{ from: string; to: string; value: string }> = [];
|
||||
const prefix = oldBase + '/';
|
||||
|
||||
for (const [key, value] of this.folderColors.entries()) {
|
||||
if (key === oldBase) {
|
||||
updates.push({ from: key, to: newBase, value });
|
||||
} else if (key.startsWith(prefix)) {
|
||||
const remainder = key.slice(prefix.length);
|
||||
updates.push({ from: key, to: `${newBase}/${remainder}`, value });
|
||||
}
|
||||
}
|
||||
|
||||
for (const u of updates) {
|
||||
this.folderColors.delete(u.from);
|
||||
this.folderColors.set(u.to, u.value);
|
||||
}
|
||||
}
|
||||
|
||||
private removeFolderColorsRecursively(base: string) {
|
||||
if (!base) return;
|
||||
const toDelete: string[] = [];
|
||||
const prefix = base + '/';
|
||||
for (const key of this.folderColors.keys()) {
|
||||
if (key === base || key.startsWith(prefix)) {
|
||||
toDelete.push(key);
|
||||
}
|
||||
}
|
||||
for (const k of toDelete) this.folderColors.delete(k);
|
||||
}
|
||||
|
||||
onFileSelected(noteId: string) {
|
||||
if(noteId) {
|
||||
this.fileSelected.emit(noteId);
|
||||
@@ -124,4 +253,234 @@ export class FileExplorerComponent {
|
||||
isFolder(node: VaultNode): node is VaultFolder {
|
||||
return node.type === 'folder';
|
||||
}
|
||||
|
||||
// Context menu methods
|
||||
openContextMenu(event: MouseEvent, folder: VaultFolder) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.ctxTarget = folder;
|
||||
this.ctxX.set(event.clientX);
|
||||
this.ctxY.set(event.clientY);
|
||||
this.ctxVisible.set(true);
|
||||
}
|
||||
|
||||
onContextMenuAction(action: string) {
|
||||
if (!this.ctxTarget) return;
|
||||
|
||||
switch (action) {
|
||||
case 'create-subfolder':
|
||||
this.createSubfolder();
|
||||
break;
|
||||
case 'rename':
|
||||
this.openRenameModal();
|
||||
break;
|
||||
case 'duplicate':
|
||||
this.duplicateFolder();
|
||||
break;
|
||||
case 'create-page':
|
||||
this.createPageInFolder();
|
||||
break;
|
||||
case 'copy-link':
|
||||
this.copyInternalLink();
|
||||
break;
|
||||
case 'delete-folder':
|
||||
this.deleteFolder();
|
||||
break;
|
||||
case 'delete-all':
|
||||
this.deleteAllPagesInFolder();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onContextMenuColor(color: string) {
|
||||
if (!this.ctxTarget) return;
|
||||
this.setFolderColor(this.ctxTarget.path, color);
|
||||
this.showNotification(`Folder color updated to ${color}`, 'success');
|
||||
}
|
||||
|
||||
// Action implementations
|
||||
private createSubfolder() {
|
||||
if (!this.ctxTarget) return;
|
||||
const name = prompt('Enter subfolder name:');
|
||||
if (!name) return;
|
||||
const newPath = `${this.ctxTarget.path}/${name}`;
|
||||
// TODO: Implement actual folder creation via VaultService
|
||||
this.showNotification(`Creating subfolder: ${newPath}`, 'info');
|
||||
}
|
||||
|
||||
private openRenameModal() {
|
||||
if (!this.ctxTarget) return;
|
||||
this.renameTarget = this.ctxTarget;
|
||||
this.renameInputValue = this.ctxTarget.name;
|
||||
this.renameModalVisible.set(true);
|
||||
}
|
||||
|
||||
private async confirmRename() {
|
||||
if (!this.renameTarget || !this.renameInputValue.trim()) return;
|
||||
|
||||
const newName = this.renameInputValue.trim();
|
||||
if (newName === this.renameTarget.name) {
|
||||
this.cancelRename();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/folders/rename', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
oldPath: this.renameTarget.path,
|
||||
newName: newName
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Failed to rename folder');
|
||||
}
|
||||
|
||||
// Compute new folder path (relative)
|
||||
const oldPath = this.renameTarget.path;
|
||||
const parent = oldPath.includes('/') ? oldPath.slice(0, oldPath.lastIndexOf('/')) : '';
|
||||
const newPath = parent ? `${parent}/${newName}` : newName;
|
||||
|
||||
// Migrate colors for the folder and its subfolders
|
||||
this.migrateFolderColors(oldPath, newPath);
|
||||
this.persistFolderColors();
|
||||
|
||||
this.showNotification(`Folder renamed to "${newName}" successfully`, 'success');
|
||||
this.cancelRename();
|
||||
|
||||
// Trigger an authoritative refresh to avoid any stale state until SSE arrives
|
||||
this.vaultService.refreshFoldersTree(true);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Rename folder error:', error);
|
||||
this.showNotification(`Failed to rename folder: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
private cancelRename() {
|
||||
this.renameModalVisible.set(false);
|
||||
this.renameInputValue = '';
|
||||
this.renameTarget = null;
|
||||
}
|
||||
|
||||
private duplicateFolder() {
|
||||
if (!this.ctxTarget) return;
|
||||
const newName = prompt('Enter duplicate folder name:', `${this.ctxTarget.name} (copy)`);
|
||||
if (!newName) return;
|
||||
const src = this.ctxTarget.path;
|
||||
const parent = src.includes('/') ? src.slice(0, src.lastIndexOf('/')) : '';
|
||||
const dst = parent ? `${parent}/${newName}` : newName;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/folders/duplicate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sourcePath: src, destinationPath: dst })
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to duplicate folder');
|
||||
this.showNotification(`Folder duplicated to: ${dst}`, 'success');
|
||||
this.vaultService.refreshFoldersTree(true);
|
||||
} catch (err) {
|
||||
console.error('Duplicate folder error:', err);
|
||||
this.showNotification(`Failed to duplicate folder: ${err.message}`, 'error');
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
private createPageInFolder() {
|
||||
if (!this.ctxTarget) return;
|
||||
const pageName = prompt('Enter page name:');
|
||||
if (!pageName) return;
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await this.noteCreation.createNote(pageName, this.ctxTarget!.path);
|
||||
this.showNotification(`Page created: ${resp.fileName}`, 'success');
|
||||
this.vaultService.refreshFoldersTree(true);
|
||||
} catch (e:any) {
|
||||
console.error('Create page error:', e);
|
||||
this.showNotification(`Failed to create page: ${e.message || e}`, 'error');
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
private copyInternalLink() {
|
||||
if (!this.ctxTarget) return;
|
||||
const link = `[[${this.ctxTarget.path}]]`;
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
this.showNotification('Internal link copied to clipboard!', 'success');
|
||||
}).catch(() => {
|
||||
this.showNotification('Failed to copy link', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
private deleteFolder() {
|
||||
if (!this.ctxTarget) return;
|
||||
const confirmed = confirm(`Are you sure you want to delete the folder "${this.ctxTarget.name}"?`);
|
||||
if (!confirmed) return;
|
||||
const target = this.ctxTarget;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/folders?path=' + encodeURIComponent(target.path), {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to delete folder');
|
||||
|
||||
// Remove any stored colors for this folder and its descendants
|
||||
this.removeFolderColorsRecursively(target.path);
|
||||
this.persistFolderColors();
|
||||
|
||||
this.showNotification(`Folder deleted: ${target.name}`, 'success');
|
||||
this.ctxVisible.set(false);
|
||||
|
||||
// Trigger an authoritative refresh to avoid any stale state until SSE arrives
|
||||
this.vaultService.refreshFoldersTree(true);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Delete folder error:', err);
|
||||
this.showNotification(`Failed to delete folder: ${err.message}`, 'error');
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
private deleteAllPagesInFolder() {
|
||||
if (!this.ctxTarget) return;
|
||||
const confirmed = confirm(`Are you sure you want to delete ALL pages in "${this.ctxTarget.name}"? This cannot be undone.`);
|
||||
if (!confirmed) return;
|
||||
const target = this.ctxTarget.path;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/folders/pages?path=' + encodeURIComponent(target), { method: 'DELETE' });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to delete pages');
|
||||
this.showNotification(`Deleted ${data.deletedCount ?? 0} pages in folder: ${this.ctxTarget!.name}`, 'success');
|
||||
this.vaultService.refreshFoldersTree(true);
|
||||
} catch (err:any) {
|
||||
console.error('Delete all pages error:', err);
|
||||
this.showNotification(`Failed to delete pages: ${err.message || err}`, 'error');
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
private showNotification(message: string, type: 'success' | 'info' | 'warning' | 'error') {
|
||||
// Simple notification using browser's native capabilities
|
||||
console.log(`[${type.toUpperCase()}] ${message}`);
|
||||
// You can replace this with a proper toast notification service if available
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.loadFolderColors();
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
// Initialize component
|
||||
}
|
||||
}
|
||||
@@ -145,7 +145,7 @@ export class VaultService implements OnDestroy {
|
||||
// ========================================
|
||||
|
||||
private initialize(): void {
|
||||
this.loadFastFileTree();
|
||||
this.loadFastFileTree(true);
|
||||
this.refreshNotes();
|
||||
this.observeVaultEvents();
|
||||
}
|
||||
@@ -326,6 +326,18 @@ export class VaultService implements OnDestroy {
|
||||
this.refreshNotes();
|
||||
}
|
||||
|
||||
/** Force a refresh of the folders tree from authoritative source (filesystem).
|
||||
* When forceFs is true, bypass Meilisearch and read directly from disk to avoid ghosts.
|
||||
*/
|
||||
refreshFoldersTree(forceFs: boolean = true): void {
|
||||
this.loadFastFileTree(forceFs);
|
||||
}
|
||||
|
||||
/** Validate the current folders tree against the real filesystem and purge ghosts. */
|
||||
async validateFolderTree(): Promise<void> {
|
||||
this.loadFastFileTree(true);
|
||||
}
|
||||
|
||||
getFastMetaById(id: string): FileMetadata | undefined {
|
||||
const path = this.idToPathIndex.get(id) || this.slugIdToPathIndex.get(id);
|
||||
return path ? this.metaByPathIndex.get(path) : undefined;
|
||||
@@ -348,11 +360,19 @@ export class VaultService implements OnDestroy {
|
||||
// FAST FILE TREE
|
||||
// ========================================
|
||||
|
||||
private loadFastFileTree(): void {
|
||||
this.http.get<FileMetadata[]>(FILES_METADATA_ENDPOINT).subscribe({
|
||||
private loadFastFileTree(forceFs: boolean = false): void {
|
||||
const url = forceFs ? `${FILES_METADATA_ENDPOINT}?source=fs` : FILES_METADATA_ENDPOINT;
|
||||
this.http.get<FileMetadata[]>(url).subscribe({
|
||||
next: (items) => {
|
||||
try {
|
||||
this.buildFastTree(items || []);
|
||||
// Also merge in empty folders from filesystem listing
|
||||
this.http.get<string[]>('/api/folders/list').subscribe({
|
||||
next: (folders) => {
|
||||
try { this.addMissingEmptyFolders(folders || []); } catch {}
|
||||
},
|
||||
error: () => { /* ignore */ }
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[VaultService] Failed to build fast tree:', e);
|
||||
}
|
||||
@@ -363,6 +383,48 @@ export class VaultService implements OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
/** Ensure empty folders from the FS listing are present in the fast tree. */
|
||||
private addMissingEmptyFolders(folderPaths: string[]): void {
|
||||
const current = this.fastTreeSignal();
|
||||
// Build a quick map from path -> folder node
|
||||
const folderMap = new Map<string, VaultFolder>();
|
||||
const visit = (nodes: VaultNode[]) => {
|
||||
for (const n of nodes) {
|
||||
if (n.type === 'folder') {
|
||||
folderMap.set(n.path, n);
|
||||
visit(n.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(current);
|
||||
|
||||
const rootWrapper: VaultFolder = { type: 'folder', name: 'root', path: '', isOpen: true, children: current };
|
||||
const getOrCreate = (accPath: string, name: string, parent: VaultFolder): VaultFolder => {
|
||||
let folder = folderMap.get(accPath);
|
||||
if (!folder) {
|
||||
folder = this.createFolder(name, accPath, this.openFolderPaths().has(accPath));
|
||||
parent.children.push(folder);
|
||||
folderMap.set(accPath, folder);
|
||||
}
|
||||
return folder;
|
||||
};
|
||||
|
||||
for (const raw of folderPaths) {
|
||||
const rel = (raw || '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
||||
if (!rel || rel === '.') continue;
|
||||
const parts = rel.split('/');
|
||||
let acc = '';
|
||||
let parent = rootWrapper;
|
||||
for (const seg of parts) {
|
||||
acc = acc ? `${acc}/${seg}` : seg;
|
||||
parent = getOrCreate(acc, seg, parent);
|
||||
}
|
||||
}
|
||||
|
||||
this.sortFolderChildren(rootWrapper);
|
||||
this.fastTreeSignal.set([...rootWrapper.children]);
|
||||
}
|
||||
|
||||
private buildFastTree(items: FileMetadata[]): void {
|
||||
this.clearFastIndices();
|
||||
|
||||
@@ -745,6 +807,15 @@ export class VaultService implements OnDestroy {
|
||||
private handleVaultEvent(event: VaultEventPayload): void {
|
||||
if (!event?.event) return;
|
||||
|
||||
// Handle folder-specific events with immediate refresh
|
||||
if (event.event === 'folder-rename' || event.event === 'folder-delete') {
|
||||
console.log(`[VaultService] Received ${event.event} event:`, event);
|
||||
// Immediate refresh for folder operations (no debounce)
|
||||
this.refreshNotes();
|
||||
this.loadFastFileTree(true); // authoritative refresh to avoid stale folders
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshEvents = ['add', 'change', 'unlink', 'addDir', 'unlinkDir', 'ready', 'connected'];
|
||||
|
||||
if (refreshEvents.includes(event.event)) {
|
||||
@@ -763,7 +834,7 @@ export class VaultService implements OnDestroy {
|
||||
this.refreshTimeoutId = null;
|
||||
// Refresh notes contents and also reload the fast file tree to reflect new/removed folders
|
||||
this.refreshNotes();
|
||||
this.loadFastFileTree();
|
||||
this.loadFastFileTree(true);
|
||||
}, REFRESH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user