-
-
{{ selectedNote?.title || 'Aucune page' }}
-
+ @if (mobileNav.activeTab() === 'list') {
+
-
-
+ }
+
+ @if (mobileNav.activeTab() === 'page') {
+
+
+
{{ selectedNote?.title || 'Aucune page' }}
+
+
+ @if (selectedNote) {
+
+ } @else {
+
Aucune page sélectionnée pour le moment.
+ }
+
+ }
diff --git a/src/app/shared/markdown/markdown-frontmatter.util.ts b/src/app/shared/markdown/markdown-frontmatter.util.ts
new file mode 100644
index 0000000..be601bb
--- /dev/null
+++ b/src/app/shared/markdown/markdown-frontmatter.util.ts
@@ -0,0 +1,29 @@
+export function rewriteTagsFrontmatter(rawMarkdown: string, tags: string[]): string {
+ const content = rawMarkdown.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)/);
+ const buildTagsBlock = (list: string[]) => {
+ const uniq = Array.from(new Set(list.map(t => `${t}`.trim()).filter(Boolean)));
+ if (!uniq.length) return 'tags: []\n';
+ return ['tags:', ...uniq.map(t => ` - ${t}`)].join('\n') + '\n';
+ };
+
+ if (!fmMatch) {
+ const tagsBlock = buildTagsBlock(tags);
+ return `---\n${tagsBlock}---\n` + content;
+ }
+
+ const fmText = fmMatch[1];
+ const body = fmMatch[2] || '';
+
+ // Replace existing tags block if present
+ const tagsRe = /(^|\n)tags\s*:[^\n]*\n(?:\s*-\s*.*\n)*/i;
+ const newTagsBlock = `\n${buildTagsBlock(tags)}`;
+ if (tagsRe.test(fmText)) {
+ const replaced = fmText.replace(tagsRe, newTagsBlock);
+ return `---\n${replaced}\n---\n${body}`.replace(/\n{3,}/g, '\n\n');
+ }
+
+ // Append tags block at the end of frontmatter
+ const fmWithTags = fmText.replace(/\n*$/, '\n') + buildTagsBlock(tags);
+ return `---\n${fmWithTags}---\n${body}`;
+}
diff --git a/src/app/shared/tags-editor/tags-editor.component.html b/src/app/shared/tags-editor/tags-editor.component.html
new file mode 100644
index 0000000..f5947c1
--- /dev/null
+++ b/src/app/shared/tags-editor/tags-editor.component.html
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ t }}
+
+
+ 3">
+ +{{ _tags().length - 3 }}
+
+
+
+
+ Ajouter des tags…
+
+
+
+
+
+
+
+
+
+ {{ t }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/shared/tags-editor/tags-editor.component.ts b/src/app/shared/tags-editor/tags-editor.component.ts
new file mode 100644
index 0000000..09f9a4c
--- /dev/null
+++ b/src/app/shared/tags-editor/tags-editor.component.ts
@@ -0,0 +1,184 @@
+import { Component, ChangeDetectionStrategy, ElementRef, HostListener, Input, Output, EventEmitter, computed, effect, inject, signal } from '@angular/core';
+import { CommonModule, NgFor, NgIf, NgClass } from '@angular/common';
+import { VaultService } from '../../../services/vault.service';
+import { ToastService } from '../toast/toast.service';
+
+@Component({
+ selector: 'app-tags-editor',
+ standalone: true,
+ imports: [CommonModule, NgFor, NgIf, NgClass],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ templateUrl: './tags-editor.component.html'
+})
+export class TagsEditorComponent {
+ private host = inject(ElementRef
);
+ private vault = inject(VaultService);
+ private toast = inject(ToastService);
+
+ @Input() noteId = '';
+ private readonly externalTags = signal([]);
+ @Input() set tags(value: string[] | null | undefined) {
+ const normalized = this.normalizeTags(value);
+ this.externalTags.set(normalized);
+ if (!this.editing()) {
+ this._tags.set([...normalized]);
+ }
+ }
+ get tags(): string[] { return this.externalTags(); }
+ @Output() tagsChange = new EventEmitter();
+ @Output() commit = new EventEmitter();
+
+ readonly _tags = signal([]);
+ readonly editing = signal(false);
+ readonly inputValue = signal('');
+ readonly hover = signal(false);
+ readonly focusedChip = signal(null);
+ readonly menuOpen = signal(false);
+ readonly menuIndex = signal(-1);
+ readonly highlightedIndex = signal(null);
+
+ // Suggestions from vault, filtered on prefix (case-insensitive)
+ readonly allTagNames = computed(() => this.vault.tags().map(t => t.name));
+ readonly suggestions = computed(() => {
+ const q = this.inputValue().trim().toLowerCase();
+ const exist = new Set(this._tags().map(t => t.toLowerCase()));
+ const base = this.allTagNames();
+ const pool = q.length < 1 ? base : base.filter(name => name.toLowerCase().startsWith(q));
+ return pool.slice(0, 200);
+ });
+
+ isSelected(name: string): boolean {
+ const lower = name.toLowerCase();
+ return this._tags().some(t => t.toLowerCase() === lower);
+ }
+
+ // Public API
+ enterEdit() {
+ this._tags.set([...this.externalTags()]);
+ this.editing.set(true);
+ queueMicrotask(() => this.focusInput());
+ this.menuOpen.set(true);
+ this.menuIndex.set(0);
+ }
+
+ blurEdit() {
+ this.editing.set(false);
+ this.menuOpen.set(false);
+ this.menuIndex.set(-1);
+ this.commit.emit();
+ this._tags.set([...this.externalTags()]);
+ }
+
+ addTagFromInput() {
+ const raw = this.inputValue().trim();
+ if (!raw) return;
+ this.addTag(raw);
+ }
+
+ addTag(tag: string) {
+ const value = `${tag}`.trim();
+ if (!value) return;
+ const existing = this._tags();
+ const lower = value.toLowerCase();
+ const existingIndex = existing.findIndex(t => t.toLowerCase() === lower);
+ if (existingIndex === -1) {
+ const next = [...existing, value];
+ this._tags.set(next);
+ this.tagsChange.emit(next);
+ } else {
+ // Visual feedback + toast
+ this.highlightedIndex.set(existingIndex);
+ setTimeout(() => this.highlightedIndex.set(null), 600);
+ this.toast.info('Tag déjà présent');
+ return;
+ }
+ this.inputValue.set('');
+ this.menuIndex.set(0);
+ this.menuOpen.set(true);
+ this.focusInput();
+ }
+
+ removeTagAt(index: number) {
+ const next = this._tags().slice();
+ next.splice(index, 1);
+ this._tags.set(next);
+ this.tagsChange.emit(next);
+ this.menuOpen.set(true);
+ this.menuIndex.set(Math.min(index, next.length - 1));
+ this.focusInput();
+ }
+
+ onInput(ev: Event) {
+ const v = (ev.target as HTMLInputElement).value || '';
+ this.inputValue.set(v);
+ this.menuOpen.set(true);
+ this.menuIndex.set(v.trim() ? -1 : 0);
+ }
+
+ pickSuggestion(i: number) {
+ const list = this.suggestions();
+ if (i >= 0 && i < list.length) {
+ if (this.isSelected(list[i])) return; // ignore selected
+ this.addTag(list[i]);
+ } else {
+ // Create option
+ const q = this.inputValue().trim();
+ if (q) this.addTag(q);
+ }
+ }
+
+ focusInput() {
+ const el = this.host.nativeElement.querySelector('input[data-tag-input]') as HTMLInputElement | null;
+ el?.focus();
+ }
+
+ // Keyboard handling on the input
+ onInputKeydown(ev: KeyboardEvent) {
+ if (ev.key === 'Enter') {
+ ev.preventDefault();
+ if (this.menuOpen() && this.menuIndex() >= 0) {
+ this.pickSuggestion(this.menuIndex());
+ } else {
+ this.addTagFromInput();
+ }
+ return;
+ }
+ if (ev.key === 'Backspace' && !this.inputValue()) {
+ const arr = this._tags();
+ if (arr.length) this.removeTagAt(arr.length - 1);
+ return;
+ }
+ if (ev.key === 'ArrowDown') {
+ ev.preventDefault();
+ const max = this.suggestions().length - 1;
+ if (max >= 0) this.menuIndex.set(Math.min(max, this.menuIndex() + 1));
+ this.menuOpen.set(true);
+ return;
+ }
+ if (ev.key === 'ArrowUp') {
+ ev.preventDefault();
+ const max = this.suggestions().length - 1;
+ if (max >= 0) this.menuIndex.set(Math.max(0, this.menuIndex() - 1));
+ return;
+ }
+ if (ev.key === 'Escape') {
+ this.menuOpen.set(false);
+ this.menuIndex.set(-1);
+ return;
+ }
+ }
+
+ private normalizeTags(value: string[] | null | undefined): string[] {
+ if (!value || !Array.isArray(value)) return [];
+ const ordered = new Map();
+ for (const raw of value) {
+ const trimmed = `${raw ?? ''}`.trim();
+ if (!trimmed) continue;
+ const lower = trimmed.toLowerCase();
+ if (!ordered.has(lower)) {
+ ordered.set(lower, trimmed);
+ }
+ }
+ return Array.from(ordered.values());
+ }
+}
diff --git a/src/app/shared/toast/index.ts b/src/app/shared/toast/index.ts
new file mode 100644
index 0000000..3078d3d
--- /dev/null
+++ b/src/app/shared/toast/index.ts
@@ -0,0 +1,3 @@
+export * from './toast.model';
+export * from './toast.service';
+export * from './toast-container.component';
diff --git a/src/app/shared/toast/toast-container.component.html b/src/app/shared/toast/toast-container.component.html
new file mode 100644
index 0000000..58f01ce
--- /dev/null
+++ b/src/app/shared/toast/toast-container.component.html
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+ 💬
+ ✅
+ ⚠️
+ ⛔
+
+
{{ t.message }}
+
+
+
+
+
+
+
diff --git a/src/app/shared/toast/toast-container.component.ts b/src/app/shared/toast/toast-container.component.ts
new file mode 100644
index 0000000..8a3625c
--- /dev/null
+++ b/src/app/shared/toast/toast-container.component.ts
@@ -0,0 +1,15 @@
+import { Component, computed, inject } from '@angular/core';
+import { NgFor, NgIf, NgClass } from '@angular/common';
+import { ToastService } from './toast.service';
+
+@Component({
+ selector: 'app-toast-container',
+ standalone: true,
+ imports: [NgFor, NgIf, NgClass],
+ templateUrl: './toast-container.component.html'
+})
+export class ToastContainerComponent {
+ private svc = inject(ToastService);
+ toasts = computed(() => this.svc.toasts().slice().reverse());
+ dismiss(id: string) { this.svc.dismiss(id); }
+}
diff --git a/src/app/shared/toast/toast.model.ts b/src/app/shared/toast/toast.model.ts
new file mode 100644
index 0000000..3c979d2
--- /dev/null
+++ b/src/app/shared/toast/toast.model.ts
@@ -0,0 +1,11 @@
+export type ToastType = 'info' | 'success' | 'warning' | 'error';
+
+export interface Toast {
+ id: string;
+ type: ToastType;
+ message: string;
+ duration: number; // en ms
+ startedAt: number; // performance.now()
+ progress: number; // 1 → 0
+ closing?: boolean; // pour transition de sortie
+}
diff --git a/src/app/shared/toast/toast.service.ts b/src/app/shared/toast/toast.service.ts
new file mode 100644
index 0000000..82c7be8
--- /dev/null
+++ b/src/app/shared/toast/toast.service.ts
@@ -0,0 +1,59 @@
+import { Injectable, signal } from '@angular/core';
+import { Toast } from './toast.model';
+
+@Injectable({ providedIn: 'root' })
+export class ToastService {
+ readonly toasts = signal([]);
+
+ show(message: string, type: Toast['type'] = 'info', duration = 4000) {
+ const id = (globalThis.crypto?.randomUUID?.() ?? `${Date.now()}_${Math.random().toString(36).slice(2)}`);
+ const now = globalThis.performance?.now?.() ?? Date.now();
+ const toast: Toast = { id, type, message, duration, startedAt: now, progress: 1 };
+
+ // empile (on rendra en reverse pour bas->haut)
+ this.toasts.update(list => [toast, ...list]);
+
+ // boucle rAF pour progression
+ let raf = 0 as any;
+ const tick = () => {
+ const list = this.toasts();
+ const t = list.find(x => x.id === id);
+ if (!t) return cancelAnimationFrame(raf);
+
+ const nowTick = globalThis.performance?.now?.() ?? Date.now();
+ const elapsed = nowTick - t.startedAt;
+ const remaining = Math.max(0, t.duration - elapsed);
+ const progress = t.duration > 0 ? remaining / t.duration : 0;
+
+ this.toasts.update(arr => arr.map(it => it.id === id ? { ...it, progress } : it));
+
+ if (remaining <= 0) {
+ this.dismiss(id);
+ } else {
+ raf = requestAnimationFrame(tick);
+ }
+ };
+ raf = requestAnimationFrame(tick);
+
+ return id;
+ }
+
+ dismiss(id: string) {
+ // transition de sortie
+ this.toasts.update(list => list.map(t => t.id === id ? { ...t, closing: true } : t));
+ setTimeout(() => {
+ this.toasts.update(list => list.filter(t => t.id !== id));
+ }, 360);
+ }
+
+ clearAll() {
+ const ids = this.toasts().map(t => t.id);
+ ids.forEach(id => this.dismiss(id));
+ }
+
+ // Helpers de compatibilité
+ info(message: string, duration = 3500) { return this.show(message, 'info', duration); }
+ success(message: string, duration = 3000) { return this.show(message, 'success', duration); }
+ warning(message: string, duration = 5000) { return this.show(message, 'warning', duration); }
+ error(message: string, duration = 6000) { return this.show(message, 'error', duration); }
+}
diff --git a/src/app/shared/toast/toast.utils.ts b/src/app/shared/toast/toast.utils.ts
new file mode 100644
index 0000000..f09b646
--- /dev/null
+++ b/src/app/shared/toast/toast.utils.ts
@@ -0,0 +1,7 @@
+export function uid(prefix = 't'): string {
+ return `${prefix}_${Math.random().toString(36).slice(2, 8)}${Date.now().toString(36).slice(-4)}`;
+}
+
+export function dedupeKey(type: string | undefined, title: string, description?: string): string {
+ return `${type || 'info'}|${title}|${description || ''}`;
+}
diff --git a/src/components/tags-view/note-viewer/note-viewer.component.ts b/src/components/tags-view/note-viewer/note-viewer.component.ts
index 480e905..e7d0ce5 100644
--- a/src/components/tags-view/note-viewer/note-viewer.component.ts
+++ b/src/components/tags-view/note-viewer/note-viewer.component.ts
@@ -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: `
{{ copyStatus() }}
-
+
-
-
@@ -200,14 +199,14 @@ interface MetadataEntry {
{{ note().updatedAt | date:'medium' }}
{{ getAuthorFromFrontmatter() ?? note().author ?? 'Auteur inconnu' }}
@@ -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
();
private readonly copyFeedbackTimers = new Map();
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
- 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 {
- 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);
- }
}
diff --git a/src/services/vault.service.ts b/src/services/vault.service.ts
index ace1cfb..e6bcd8e 100644
--- a/src/services/vault.service.ts
+++ b/src/services/vault.service.ts
@@ -3,6 +3,7 @@ import { HttpClient } from '@angular/common/http';
import { Note, VaultNode, GraphData, TagInfo, VaultFolder, FileMetadata } from '../types';
import { VaultEventsService, VaultEventPayload } from './vault-events.service';
import { Subscription, firstValueFrom } from 'rxjs';
+import { rewriteTagsFrontmatter } from '../app/shared/markdown/markdown-frontmatter.util';
interface VaultApiNote {
id: string;
@@ -727,4 +728,66 @@ export class VaultService implements OnDestroy {
.replace(/[-_]+/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase());
}
+
+ /**
+ * Update tags for a given note id and persist to the underlying Markdown file.
+ * Automatically refreshes the in-memory note and emits to listeners.
+ */
+ async updateNoteTags(noteId: string, tags: string[]): Promise {
+ const note = this.getNoteById(noteId);
+ if (!note || !note.filePath) return false;
+
+ const currentRaw = note.rawContent ?? this.recomposeMarkdownFromNote(note);
+ const nextRaw = rewriteTagsFrontmatter(currentRaw, tags);
+
+ const ok = await this.saveMarkdown(note.filePath, nextRaw);
+ if (!ok) return false;
+
+ // Update local cache
+ const updated: Note = {
+ ...note,
+ rawContent: nextRaw,
+ tags: Array.from(new Set((tags || []).map(t => `${t}`.trim()).filter(Boolean))),
+ // Keep frontmatter reference; caller can reparse if needed later
+ } as Note;
+
+ const mapCopy = new Map(this.notesMap());
+ mapCopy.set(updated.id, updated);
+ this.notesMap.set(mapCopy);
+ return true;
+ }
+
+ /** PUT the markdown file content via backend API */
+ private async saveMarkdown(filePath: string, content: string): Promise {
+ try {
+ const url = `/api/files?path=${encodeURIComponent(filePath)}`;
+ await firstValueFrom(this.http.put(url, content, { headers: { 'Content-Type': 'text/markdown' } }));
+ return true;
+ } catch (e) {
+ console.error('[VaultService] saveMarkdown failed', e);
+ return false;
+ }
+ }
+
+ /** Fallback to rebuild markdown when rawContent is missing */
+ private recomposeMarkdownFromNote(note: Note): string {
+ // Minimal recomposition: frontmatter from existing parsed frontmatter, then body from note.content
+ try {
+ const fm = note.frontmatter || {};
+ const lines: string[] = ['---'];
+ for (const [k, v] of Object.entries(fm)) {
+ if (k === 'tags') continue; // will be handled by rewriteTagsFrontmatter
+ if (Array.isArray(v)) {
+ lines.push(`${k}:`);
+ for (const item of v) lines.push(` - ${item}`);
+ } else {
+ lines.push(`${k}: ${v}`);
+ }
+ }
+ lines.push('---', (note.content ?? '').replace(/\r\n?/g, '\n'));
+ return lines.join('\n');
+ } catch {
+ return note.content ?? '';
+ }
+ }
}
\ No newline at end of file
diff --git a/src/styles/components.css b/src/styles/components.css
index e49f9e3..7f50468 100644
--- a/src/styles/components.css
+++ b/src/styles/components.css
@@ -151,6 +151,12 @@
font-size: 1.5rem;
}
+ .note-toolbar-path {
+ margin-left: 0;
+ font-size: 0.95rem;
+ padding-inline: 6px;
+ }
+
.note-toolbar-icon:hover {
color: var(--text-main);
background-color: color-mix(in srgb, var(--bg-muted) 70%, transparent);