diff --git a/src/app.component.simple.html b/src/app.component.simple.html index b063963..a9939c4 100644 --- a/src/app.component.simple.html +++ b/src/app.component.simple.html @@ -1,4 +1,5 @@ + @if (uiMode.isNimbusMode()) { -
+
{{ f === 'quick' ? 'Quick Links' : (f === 'folders' ? 'Folders' : (f === 'tags' ? 'Tags' : 'Trash')) }}
@@ -192,17 +192,25 @@ import { MarkdownPlaygroundComponent } from '../../features/tests/markdown-playg
-
- -
- -
-
-

{{ 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 }} + + + + +{{ _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() }}
-
+
- - + - - @if (note().tags.length > 0) { - - } - + +
@@ -200,14 +199,14 @@ interface MetadataEntry { + 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" /> {{ note().updatedAt | date:'medium' }} + 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" /> {{ 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);