feat: improve note path handling and add delete confirmation modal

- Updated API endpoints to handle note paths with slashes using regex routes
- Added warning modal component for note deletion confirmation
- Fixed URL encoding of note paths to preserve spaces and special characters
- Improved fullscreen note handling to use URL state and custom events
- Enhanced internal link copying to use proper URL format
- Removed test/sample note files from vault directory
This commit is contained in:
2025-10-25 16:27:42 -04:00
parent 96745e9997
commit b873577e93
11 changed files with 420 additions and 54 deletions
@@ -0,0 +1,89 @@
import { Component, ChangeDetectionStrategy, EventEmitter, Input, Output, OnInit, OnDestroy, ElementRef, Renderer2, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-warning-panel',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div *ngIf="visible" class="fixed inset-0 z-[99999] flex items-center justify-center bg-black/40 backdrop-blur-[1px]" (click)="onBackdrop($event)" role="dialog" aria-modal="true">
<div class="w-full max-w-md mx-3 rounded-2xl shadow-xl transition-all duration-200 p-6 border"
[class.bg-white]="!isDarkTheme()" [class.border-slate-200]="!isDarkTheme()"
[class.bg-slate-800/95]="isDarkTheme()" [class.border-slate-600]="isDarkTheme()"
(click)="$event.stopPropagation()">
<div class="flex items-center gap-3 mb-4">
<svg class="w-6 h-6 text-yellow-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
<h2 class="text-lg font-semibold">{{ title }}</h2>
</div>
<p class="text-sm mb-6" [class.text-slate-500]="!isDarkTheme()" [class.text-slate-300]="isDarkTheme()">{{ message }}</p>
<div class="flex justify-end gap-3">
<button type="button" (click)="onCancel()"
class="px-4 py-2 rounded-lg transition-colors cursor-pointer"
[ngClass]="isDarkTheme() ? 'text-slate-300 hover:bg-slate-700' : 'text-slate-700 hover:bg-slate-100'">{{ cancelText || 'Cancel' }}</button>
<button type="button" (click)="onDelete()"
class="px-4 py-2 rounded-lg text-white transition-colors cursor-pointer"
[ngClass]="confirmColor === 'danger' ? 'bg-red-600 hover:bg-red-700' : 'bg-blue-600 hover:bg-blue-700'">
{{ confirmText || 'Delete' }}
</button>
</div>
</div>
</div>
`,
})
export class WarningPanelComponent implements OnInit, OnDestroy {
@Input() visible = false;
@Input() title = 'Delete this note?';
@Input() message = 'The note will be moved to the trash folder and can be restored later.';
@Input() confirmText = 'Delete';
@Input() cancelText = 'Cancel';
@Input() confirmColor: 'danger' | 'primary' = 'danger';
@Output() cancel = new EventEmitter<void>();
@Output() delete = new EventEmitter<void>();
private readonly host = inject(ElementRef<HTMLElement>);
private readonly renderer = inject(Renderer2);
// Use documentElement dark class to decide theme quickly without injecting ThemeService
isDarkTheme(): boolean {
try { return document.documentElement.classList.contains('dark'); } catch { return false; }
}
ngOnInit(): void {
if (typeof document === 'undefined') return;
const element = this.host.nativeElement;
// Ensure host doesn't create an extra layer interfering with positioning
this.renderer.setStyle(element, 'display', 'contents');
// Move host to body so it sits above any stacking-context limitations
this.renderer.appendChild(document.body, element);
}
ngOnDestroy(): void {
if (typeof document === 'undefined') return;
const element = this.host.nativeElement;
if (element.parentNode) {
this.renderer.removeChild(element.parentNode, element);
}
}
onCancel() {
console.log('[WarningPanel] Cancel button clicked');
this.cancel.emit();
}
onDelete() {
console.log('[WarningPanel] Confirm button clicked');
this.delete.emit();
}
onBackdrop(event: MouseEvent) {
if (event.target === event.currentTarget) {
this.cancel.emit();
}
}
}
+53 -2
View File
@@ -7,12 +7,13 @@ 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';
import { NoteContextMenuComponent } from '../../../components/note-context-menu/note-context-menu.component';
import { WarningPanelComponent } from '../../components/warning-panel/warning-panel.component';
import { NoteContextMenuService } from '../../services/note-context-menu.service';
@Component({
selector: 'app-notes-list',
standalone: true,
imports: [CommonModule, ScrollableOverlayDirective, NoteContextMenuComponent],
imports: [CommonModule, ScrollableOverlayDirective, NoteContextMenuComponent, WarningPanelComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="h-full flex flex-col">
@@ -173,6 +174,17 @@ import { NoteContextMenuService } from '../../services/note-context-menu.service
(color)="onContextMenuColor($event)"
(closed)="contextMenuService.close()">
</app-note-context-menu>
<!-- Delete Warning Modal -->
<app-warning-panel
[visible]="deleteWarningOpen()"
[title]="'Delete this note?'"
[message]="'The note will be moved to the trash folder and can be restored later.'"
[confirmText]="'Delete'"
[cancelText]="'Cancel'"
[confirmColor]="'danger'"
(delete)="confirmDelete()"
(cancel)="closeDeleteWarning()"></app-warning-panel>
`,
styles: [`
:host {
@@ -367,6 +379,45 @@ export class NotesListComponent {
private noteCreationService = inject(NoteCreationService);
readonly contextMenuService = inject(NoteContextMenuService);
// Delete warning modal state
deleteWarningOpen = signal<boolean>(false);
private deleteTarget: Note | null = null;
openDeleteWarning(note: Note) {
console.log('[NotesList] Opening delete warning for note:', note.title);
// Close context menu so it does not overlay/capture clicks above the modal
this.contextMenuService.close();
this.deleteTarget = note;
this.deleteWarningOpen.set(true);
}
closeDeleteWarning() {
console.log('[NotesList] Closing delete warning');
this.deleteWarningOpen.set(false);
this.deleteTarget = null;
}
async confirmDelete() {
console.log('[NotesList] Confirm delete called for:', this.deleteTarget?.title);
const note = this.deleteTarget;
if (!note) {
console.warn('[NotesList] No delete target found');
this.closeDeleteWarning();
return;
}
try {
console.log('[NotesList] Calling deleteNoteConfirmed...');
await this.contextMenuService.deleteNoteConfirmed(note);
// Only close on success
console.log('[NotesList] Delete successful, closing modal');
this.closeDeleteWarning();
this.contextMenuService.close();
} catch (error) {
console.error('Confirm delete error:', error);
// Keep modal open on error so user can try again or cancel
}
}
private q = signal('');
activeTag = signal<string | null>(null);
sortMenuOpen = signal<boolean>(false);
@@ -595,7 +646,7 @@ export class NotesListComponent {
await this.contextMenuService.toggleReadOnly(note);
break;
case 'delete':
await this.contextMenuService.deleteNote(note);
this.openDeleteWarning(note);
break;
}
}
@@ -603,6 +603,12 @@ export class AppShellNimbusLayoutComponent {
document.body.classList.toggle('note-fullscreen-active', this.noteFullScreen);
}
/** Reuse the same behavior when a global fullscreen request event is dispatched */
@HostListener('window:noteFullScreenRequested', ['$event'])
onNoteFullScreenRequested(_evt: CustomEvent) {
this.toggleNoteFullScreen();
}
nextTab() {
const order: Array<'sidebar' | 'list' | 'page' | 'toc'> = ['sidebar', 'list', 'page', 'toc'];
const idx = order.indexOf(this.mobileNav.activeTab());
+50 -34
View File
@@ -2,11 +2,13 @@ import { Injectable, signal, inject } from '@angular/core';
import type { Note } from '../../types';
import { ToastService } from '../shared/toast/toast.service';
import { VaultService } from './vault.service';
import { UrlStateService } from './url-state.service';
@Injectable({ providedIn: 'root' })
export class NoteContextMenuService {
private readonly toast = inject(ToastService);
private readonly vaultService = inject(VaultService);
private readonly urlState = inject(UrlStateService);
// État du menu
readonly visible = signal(false);
@@ -29,9 +31,11 @@ export class NoteContextMenuService {
}
// Actions du menu
private getSanitizedId(note: Note): string {
const id = note.id || note.filePath || '';
return id.replace(/\\/g, '/').replace(/\.md$/i, '');
private getApiIdPath(note: Note): string {
// Prefer the real filePath (preserves spaces/case) over slugified id
const raw = (note.filePath || note.id || '').replace(/\\/g, '/').replace(/\.md$/i, '');
// Encode each path segment so spaces and special chars are handled, keep slashes
return raw.split('/').map(encodeURIComponent).join('/');
}
async duplicateNote(note: Note): Promise<void> {
try {
@@ -108,10 +112,18 @@ export class NoteContextMenuService {
}
openFullScreen(note: Note): void {
// Ouvre la note en plein écran via les query params
const url = `/?note=${encodeURIComponent(note.filePath)}&view=full`;
window.location.assign(url);
this.emitEvent('noteOpenedFull', { path: note.filePath });
// Sélectionner la note via l'état d'URL puis demander le plein écran
try {
if (note?.filePath) {
this.urlState.openNote(note.filePath);
}
const evt = new CustomEvent('noteFullScreenRequested', { detail: { path: note.filePath } });
window.dispatchEvent(evt);
this.emitEvent('noteOpenedFull', { path: note.filePath });
} catch (err) {
// silencieux par conception (pas de toast)
console.error('openFullScreen error', err);
}
}
async copyInternalLink(note: Note): Promise<void> {
@@ -119,7 +131,7 @@ export class NoteContextMenuService {
// Copier l'URL complète vers la note
const url = `${window.location.origin}/?note=${encodeURIComponent(note.filePath)}`;
await navigator.clipboard.writeText(url);
this.toast.success('URL copiée dans le presse-papiers');
this.toast.success('Lien de la note copié dans le presse-papiers');
this.emitEvent('noteCopiedLink', { path: note.filePath, link: url });
@@ -134,8 +146,8 @@ export class NoteContextMenuService {
const isFavorite = !note.frontmatter?.favoris;
// Mettre à jour le frontmatter (endpoint PATCH /api/vault/notes/:id)
const noteId = this.getSanitizedId(note);
const response = await fetch(`/api/vault/notes/${noteId}`, {
const apiId = this.getApiIdPath(note);
const response = await fetch(`/api/vault/notes/${apiId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ frontmatter: { favoris: isFavorite } })
@@ -196,8 +208,8 @@ ${note.frontmatter?.favoris ? '⭐ Favori' : ''}
const isReadOnly = !note.frontmatter?.readOnly;
// Mettre à jour le frontmatter (endpoint PATCH /api/vault/notes/:id)
const noteId = this.getSanitizedId(note);
const response = await fetch(`/api/vault/notes/${noteId}`, {
const apiId = this.getApiIdPath(note);
const response = await fetch(`/api/vault/notes/${apiId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ frontmatter: { readOnly: isReadOnly } })
@@ -224,42 +236,46 @@ ${note.frontmatter?.favoris ? '⭐ Favori' : ''}
async deleteNote(note: Note): Promise<void> {
try {
// Demander confirmation
// Backward-compatible method: keep native confirm then delegate to confirmed variant
const confirmMessage = note.frontmatter?.readOnly
? `Supprimer cette page ?\n\nLa page "${note.title}" sera déplacée vers .trash et pourra être restaurée.\n\n⚠️ Cette page est en lecture seule. Cochez "Je comprends" pour continuer.`
: `Supprimer cette page ?\n\nLa page "${note.title}" sera déplacée vers .trash et pourra être restaurée.`;
const confirmed = confirm(confirmMessage);
if (!confirmed) return;
// Déplacer vers la corbeille
const noteId = this.getSanitizedId(note);
const response = await fetch(`/api/vault/notes/${noteId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete note');
}
this.toast.success(`Page supprimée: ${note.title}`);
this.emitEvent('noteDeleted', { path: note.filePath });
// Rafraîchir la liste
this.vaultService.refresh();
await this.deleteNoteConfirmed(note);
} catch (error) {
console.error('Delete note error:', error);
this.toast.error('Échec de la suppression de la page');
}
}
async deleteNoteConfirmed(note: Note): Promise<void> {
try {
// Perform the actual delete (move to .trash) without any prompt
const apiId = this.getApiIdPath(note);
const response = await fetch(`/api/vault/notes/${apiId}`, { method: 'DELETE' });
if (!response.ok) {
throw new Error(`Failed to delete note: ${response.status} ${response.statusText}`);
}
// Success toast per spec
this.toast.success('Note moved to Trash.');
this.emitEvent('noteDeleted', { path: note.filePath });
// Refresh list and counts
this.vaultService.refresh();
} catch (error) {
console.error('Delete note confirmed error:', error);
this.toast.error('Échec de la suppression de la note');
throw error; // Re-throw so caller knows it failed
}
}
async changeNoteColor(note: Note, color: string): Promise<void> {
try {
// Mettre à jour le frontmatter (endpoint PATCH /api/vault/notes/:id)
const noteId = this.getSanitizedId(note);
const response = await fetch(`/api/vault/notes/${noteId}`, {
const apiId = this.getApiIdPath(note);
const response = await fetch(`/api/vault/notes/${apiId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ frontmatter: { color: color || null } })
@@ -4,6 +4,7 @@ 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 { UrlStateService } from '../../app/services/url-state.service';
import { NotesListFocusService } from '../../app/services/notes-list-focus.service';
import { FolderFilterService } from '../../app/services/folder-filter.service';
import { BadgeCountComponent } from '../../app/shared/ui/badge-count.component';
@@ -184,6 +185,7 @@ export class FileExplorerComponent {
private noteCreation = inject(NoteCreationService);
private notesListFocus = inject(NotesListFocusService);
private folderFilter = inject(FolderFilterService);
private urlState = inject(UrlStateService);
// Computed filtered nodes based on folder filter settings
filteredNodes = computed(() => {
@@ -569,11 +571,11 @@ export class FileExplorerComponent {
private copyInternalLink() {
if (!this.ctxTarget) return;
const link = `[[${this.ctxTarget.path}]]`;
navigator.clipboard.writeText(link).then(() => {
this.showNotification('Internal link copied to clipboard!', 'success');
const url = this.urlState.generateShareUrl({ folder: this.ctxTarget.path });
navigator.clipboard.writeText(url).then(() => {
this.showNotification('Folder URL copied to clipboard!', 'success');
}).catch(() => {
this.showNotification('Failed to copy link', 'error');
this.showNotification('Failed to copy URL', 'error');
});
}
@@ -45,10 +45,9 @@ type NoteAction =
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);
color: var(--text-main, var(--fg, #111827));
z-index: 10000;
}
.item {
@@ -113,6 +112,7 @@ type NoteAction =
flex-shrink: 0;
}
@keyframes fadeIn { from { opacity:0; transform: scale(.95);} to { opacity:1; transform: scale(1);} }
`],
template: `
<ng-container *ngIf="visible">