feat: add toast notifications and improve tag management UI

This commit is contained in:
2025-10-16 22:25:05 -04:00
parent 3b7a4326a5
commit 4dbcf8ad81
15 changed files with 577 additions and 42 deletions
@@ -16,6 +16,9 @@ import { Note } from '../../../types';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { NotePreviewService, PreviewData } from '../../../services/note-preview.service';
import { ClipboardService } from '../../../app/shared/services/clipboard.service';
import { ToastService } from '../../../app/shared/toast/toast.service';
import { TagsEditorComponent } from '../../../app/shared/tags-editor/tags-editor.component';
import { VaultService } from '../../../services/vault.service';
import { Subscription } from 'rxjs';
import mermaid from 'mermaid';
@@ -62,32 +65,28 @@ interface MetadataEntry {
@Component({
selector: 'app-note-viewer',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, TagsEditorComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="relative p-1 prose prose-lg dark:prose-invert max-w-none prose-p:leading-[1] prose-li:leading-[1] prose-blockquote:leading-[1]">
<div class="sr-only" role="status" aria-live="polite">{{ copyStatus() }}</div>
<!-- Compact Top Bar -->
<div class="flex items-center justify-between gap-2 px-2 py-1 mb-2 text-text-muted text-xs">
<div class="flex items-center justify-between gap-2 pl-1 pr-2 py-1 mb-2 text-text-muted text-xs">
<div class="flex items-center gap-1">
<!-- Directory clickable -->
<button type="button" class="note-toolbar-icon" (click)="directoryClicked.emit(getDirectoryFromPath(note().filePath))" title="Open directory">🗂️ {{ note().filePath }}</button>
<button type="button" class="note-toolbar-icon" (click)="copyPath()" aria-label="Copier le chemin" title="Copier le chemin">
<button type="button" class="note-toolbar-icon note-toolbar-path hidden lg:inline-flex" (click)="directoryClicked.emit(getDirectoryFromPath(note().filePath))" title="Open directory">🗂️ {{ note().filePath }}</button>
<button type="button" class="note-toolbar-icon hidden lg:inline-flex" (click)="copyPath()" aria-label="Copier le chemin" title="Copier le chemin">
<svg xmlns="http://www.w3.org/2000/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 width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16V6a2 2 0 0 1 2-2h10"/></svg>
</button>
<!-- Tags (discreet) -->
@if (note().tags.length > 0) {
<div class="hidden md:flex items-center gap-1 not-prose">
@for (tag of note().tags; track tag) {
<button type="button" class="md-tag-badge" [ngClass]="tagColorClass(tag)" data-origin="header" [attr.data-tag]="tag" (click)="tagClicked.emit(tag)">
{{ tag }}
</button>
}
</div>
}
<button type="button" class="chip" (click)="addTagRequested.emit()" title="Add tag">+ tag</button>
<!-- Tags editor (read-only -> edit on click) -->
<app-tags-editor class="hidden md:flex ml-3"
[noteId]="note().id"
[tags]="note().tags"
(tagsChange)="onTagsChange($event)"
(commit)="commitTagsNow()"
></app-tags-editor>
</div>
<div class="flex items-center gap-1">
@@ -200,14 +199,14 @@ interface MetadataEntry {
<span class="inline-flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2h-1.5a1.5 1.5 0 01-3 0h-5a1.5 1.5 0 01-3 0H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2h-1.5a1.5 1.5 0 01-3 0h-5a1.5 1.5 0 01-3 0H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
{{ note().updatedAt | date:'medium' }}
</span>
<span class="inline-flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM5.5 21a6.5 6.5 0 0113 0"/>
d="M12 8c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM5.5 21a6.5 6.5 0 0113 0" />
</svg>
{{ getAuthorFromFrontmatter() ?? note().author ?? 'Auteur inconnu' }}
</span>
@@ -322,6 +321,8 @@ export class NoteViewerComponent implements OnDestroy {
private readonly sanitizer = inject(DomSanitizer);
private readonly previewService = inject(NotePreviewService);
private readonly clipboard = inject(ClipboardService);
private readonly toast = inject(ToastService);
private readonly vault = inject(VaultService);
private readonly tagPaletteSize = 12;
private readonly tagColorCache = new Map<string, number>();
private readonly copyFeedbackTimers = new Map<HTMLElement, number>();
@@ -457,6 +458,75 @@ export class NoteViewerComponent implements OnDestroy {
});
}
private tagsSaveTimer: number | null = null;
private pendingTags: string[] | null = null;
onTagsChange(next: string[]): void {
this.pendingTags = next.slice();
if (this.tagsSaveTimer !== null) {
window.clearTimeout(this.tagsSaveTimer);
this.tagsSaveTimer = null;
}
this.tagsSaveTimer = window.setTimeout(() => this.commitTagsNow(), 1000);
}
async commitTagsNow(): Promise<void> {
if (!this.pendingTags) return;
const note = this.note();
if (!note) return;
const tags = this.pendingTags.slice().map(t => `${t}`.trim()).filter(Boolean);
this.pendingTags = null;
try {
const ok = await this.vault.updateNoteTags(note.id, tags);
if (ok) {
this.toast.success('Tags enregistrés');
} else {
this.toast.error('Échec d’enregistrement des tags');
}
} catch {
this.toast.error('Échec d’enregistrement des tags');
}
}
async copyPath(): Promise<void> {
const path = this.note()?.filePath ?? '';
try {
const ok = await this.clipboard.write(path);
if (ok) {
this.toast.success(path ? `Path copié — « ${path} »` : 'Path copié');
this.copyStatus.set('Path copié');
} else {
this.toast.error('Impossible de copier le path. Vérifiez les permissions du navigateur.');
this.copyStatus.set('Échec de la copie du path');
}
} catch {
this.toast.error('Impossible de copier le path. Vérifiez les permissions du navigateur.');
this.copyStatus.set('Échec de la copie du path');
}
}
async copyMarkdown(): Promise<void> {
const md = this.note()?.rawContent ?? this.note()?.content ?? '';
if (!md.trim()) {
this.toast.info('Document vide — Aucun contenu Markdown à copier.');
this.copyStatus.set('Document vide, rien à copier');
return;
}
try {
const ok = await this.clipboard.write(md);
if (ok) {
this.toast.success('Markdown copié — Le contenu complet est dans le presse-papiers.');
this.copyStatus.set('Markdown copié');
} else {
this.toast.error('Échec de la copie — Permission refusée. Essayez via le menu contextuel.');
this.copyStatus.set('Échec de la copie du markdown');
}
} catch {
this.toast.error('Échec de la copie — Permission refusée. Essayez via le menu contextuel.');
this.copyStatus.set('Échec de la copie du markdown');
}
}
toggleMenu(): void {
this.menuOpen.update(v => !v);
}
@@ -1133,18 +1203,4 @@ export class NoteViewerComponent implements OnDestroy {
if (idx <= 0) return p.replace(/\\/g, '/');
return p.slice(0, idx).replace(/\\/g, '/');
}
async copyPath(): Promise<void> {
const path = this.note()?.originalPath || this.note()?.filePath || '';
const ok = await this.clipboard.write(path);
this.copyStatus.set(ok ? 'Copié !' : 'Échec de la copie');
setTimeout(() => this.copyStatus.set(''), 1500);
}
async copyMarkdown(): Promise<void> {
const raw = this.note()?.rawContent || '';
const ok = await this.clipboard.write(raw);
this.copyStatus.set(ok ? 'Copié !' : 'Échec de la copie');
setTimeout(() => this.copyStatus.set(''), 1500);
}
}