feat: add toast notifications and improve tag management UI
This commit is contained in:
parent
3b7a4326a5
commit
4dbcf8ad81
@ -1,4 +1,5 @@
|
||||
<!-- ObsiViewer - Application optimisée pour mobile et desktop -->
|
||||
<app-toast-container></app-toast-container>
|
||||
@if (uiMode.isNimbusMode()) {
|
||||
<app-shell-nimbus-layout
|
||||
[vaultName]="vaultName()"
|
||||
|
||||
@ -33,6 +33,7 @@ import { GraphIndexService } from './core/graph/graph-index.service';
|
||||
import { SearchIndexService } from './core/search/search-index.service';
|
||||
import { SearchOrchestratorService } from './core/search/search-orchestrator.service';
|
||||
import { LayoutModule } from '@angular/cdk/layout';
|
||||
import { ToastContainerComponent } from './app/shared/toast/toast-container.component';
|
||||
|
||||
// Types
|
||||
import { FileMetadata, Note, TagInfo, VaultNode } from './types';
|
||||
@ -63,6 +64,7 @@ interface TocEntry {
|
||||
DrawingsEditorComponent,
|
||||
AppShellNimbusLayoutComponent,
|
||||
MarkdownPlaygroundComponent,
|
||||
ToastContainerComponent,
|
||||
],
|
||||
templateUrl: './app.component.simple.html',
|
||||
styleUrls: ['./app.component.css'],
|
||||
|
||||
@ -92,7 +92,7 @@ import { MarkdownPlaygroundComponent } from '../../features/tests/markdown-playg
|
||||
</aside>
|
||||
|
||||
<!-- Flyouts -->
|
||||
<div class="absolute left-14 top-0 bottom-0 w-80 max-w-[70vw] bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 shadow-xl" *ngIf="hoveredFlyout as f" (mouseenter)="cancelCloseFlyout()" (mouseleave)="scheduleCloseFlyout()">
|
||||
<div class="absolute left-14 top-0 bottom-0 w-80 max-w-[70vw] bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 shadow-xl z-50" *ngIf="hoveredFlyout as f" (mouseenter)="cancelCloseFlyout()" (mouseleave)="scheduleCloseFlyout()">
|
||||
<div class="h-12 flex items-center justify-between px-3 border-b border-gray-200 dark:border-gray-800">
|
||||
<div class="text-sm font-semibold">{{ f === 'quick' ? 'Quick Links' : (f === 'folders' ? 'Folders' : (f === 'tags' ? 'Tags' : 'Trash')) }}</div>
|
||||
<button class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800" (click)="hoveredFlyout=null">✕</button>
|
||||
@ -192,17 +192,25 @@ import { MarkdownPlaygroundComponent } from '../../features/tests/markdown-playg
|
||||
<div *ngIf="responsive.isMobile() && !noteFullScreen" class="flex-1 relative overflow-hidden" appSwipeNav (swipeLeft)="nextTab()" (swipeRight)="prevTab()">
|
||||
<app-sidebar-drawer [nodes]="effectiveFileTree" [selectedNoteId]="selectedNoteId" (noteSelected)="onNoteSelectedMobile($event)" (folderSelected)="onFolderSelectedFromDrawer($event)"></app-sidebar-drawer>
|
||||
|
||||
<div [hidden]="mobileNav.activeTab() !== 'list'" class="h-full flex flex-col overflow-hidden">
|
||||
@if (mobileNav.activeTab() === 'list') {
|
||||
<div class="h-full flex flex-col overflow-hidden">
|
||||
<app-notes-list class="flex-1" [notes]="vault.allNotes()" [folderFilter]="folderFilter" [tagFilter]="tagFilter" [query]="listQuery" (queryChange)="listQuery=$event" (openNote)="onNoteSelectedMobile($event)"></app-notes-list>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div [hidden]="mobileNav.activeTab() !== 'page'" class="h-full overflow-y-auto px-3 py-3" appScrollableOverlay>
|
||||
@if (mobileNav.activeTab() === 'page') {
|
||||
<div class="h-full overflow-y-auto px-3 py-3" appScrollableOverlay>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="text-base font-semibold truncate">{{ selectedNote?.title || 'Aucune page' }}</h2>
|
||||
<button *ngIf="tableOfContents.length > 0" (click)="mobileNav.toggleToc()" class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800">📋</button>
|
||||
<button *ngIf="tableOfContents.length > 0" (click)="mobileNav.toggleToc()" class="p-2 rounded hover:bg-gray-100 dark:hover-bg-gray-800">📋</button>
|
||||
</div>
|
||||
@if (selectedNote) {
|
||||
<app-note-viewer [note]="selectedNote || null" [noteHtmlContent]="renderedNoteContent" [allNotes]="vault.allNotes()" (noteLinkClicked)="noteSelected.emit($event)" (tagClicked)="tagClicked.emit($event)" (wikiLinkActivated)="wikiLinkActivated.emit($event)" (fullScreenRequested)="toggleNoteFullScreen()"></app-note-viewer>
|
||||
} @else {
|
||||
<div class="mt-10 text-center text-sm text-gray-500 dark:text-gray-400">Aucune page sélectionnée pour le moment.</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-toc-overlay *ngIf="mobileNav.tocOpen()" [headings]="tableOfContents" (go)="navigateHeading.emit($event); mobileNav.toggleToc()" (close)="mobileNav.toggleToc()"></app-toc-overlay>
|
||||
|
||||
|
||||
29
src/app/shared/markdown/markdown-frontmatter.util.ts
Normal file
29
src/app/shared/markdown/markdown-frontmatter.util.ts
Normal file
@ -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}`;
|
||||
}
|
||||
59
src/app/shared/tags-editor/tags-editor.component.html
Normal file
59
src/app/shared/tags-editor/tags-editor.component.html
Normal file
@ -0,0 +1,59 @@
|
||||
<!-- Read-only chips with lucide tag icon; click to edit -->
|
||||
<div class="not-prose group relative" (click)="enterEdit()">
|
||||
<div *ngIf="!editing(); else editTpl" class="flex items-center gap-2 text-sm ml-2">
|
||||
<span class="text-text-muted inline-flex items-center pr-1">
|
||||
<!-- lucide tag icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7.5 7.5h.01"/><path d="M3 7.5V3h4.5l7 7a3 3 0 0 1 0 4.243l-2.257 2.257a3 3 0 0 1-4.243 0l-7-7Z"/></svg>
|
||||
</span>
|
||||
<ng-container *ngIf="_tags().length; else emptyTpl">
|
||||
<div class="flex flex-wrap gap-2 max-h-[3.2rem] overflow-hidden">
|
||||
<ng-container *ngFor="let t of _tags().slice(0,3); let i = index">
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-bg-muted/80 px-2 py-0.5 text-xs font-medium text-text-main border border-border transition-colors hover:bg-gray-700">
|
||||
{{ t }}
|
||||
</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="_tags().length > 3">
|
||||
<span class="inline-flex items-center rounded-full bg-bg-muted px-2 py-0.5 text-xs font-semibold text-text-muted border border-border" title="{{ _tags().slice(3).join(', ') }}">+{{ _tags().length - 3 }}</span>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #emptyTpl>
|
||||
<span class="text-text-muted text-xs">Ajouter des tags…</span>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template #editTpl>
|
||||
<div class="relative w-full">
|
||||
<div class="flex flex-wrap items-center gap-2 rounded-xl border border-border bg-gray-800/50 px-3 py-2 shadow-subtle focus-within:ring-2 focus-within:ring-blue-500/40 transition-all duration-200 ease-out">
|
||||
<ng-container *ngFor="let t of _tags(); let i = index">
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-gray-700/60 px-2 py-0.5 text-xs font-medium text-gray-200 border border-border transition-all duration-150 hover:bg-blue-500/80"
|
||||
[ngClass]="{ 'ring-1 ring-red-400': highlightedIndex() === i }">
|
||||
{{ t }}
|
||||
<button type="button" class="ml-1 opacity-70 hover:opacity-100" (click)="removeTagAt(i)" aria-label="Supprimer">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" 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>
|
||||
</span>
|
||||
</ng-container>
|
||||
<input data-tag-input type="text" class="min-w-[8ch] flex-1 bg-transparent text-sm outline-none placeholder:text-text-muted" [value]="inputValue()" (input)="onInput($event)" (keydown)="onInputKeydown($event)" (blur)="blurEdit()" placeholder="Rechercher ou créer un tag" />
|
||||
</div>
|
||||
|
||||
<!-- Suggestions dropdown -->
|
||||
<div *ngIf="menuOpen()" class="absolute left-0 top-[calc(100%+6px)] z-50 max-h-[300px] w-[min(420px,92vw)] overflow-auto rounded-lg border border-border bg-card shadow-xl">
|
||||
<ng-container *ngIf="suggestions().length; else createTpl">
|
||||
<button type="button" *ngFor="let s of suggestions(); let i = index" (mousedown)="$event.preventDefault()" (click)="pickSuggestion(i)"
|
||||
class="block w-full text-left px-3 py-2 text-sm transition-colors hover:bg-bg-muted"
|
||||
[ngClass]="{ 'bg-bg-muted': menuIndex() === i, 'opacity-60 cursor-not-allowed': isSelected(s) }"
|
||||
[attr.aria-disabled]="isSelected(s)">
|
||||
<span>{{ s }}</span>
|
||||
<span *ngIf="isSelected(s)" class="float-right text-xs text-text-muted">déjà sélectionné</span>
|
||||
</button>
|
||||
</ng-container>
|
||||
<ng-template #createTpl>
|
||||
<button type="button" class="block w-full text-left px-3 py-2 text-sm hover:bg-bg-muted" (mousedown)="$event.preventDefault()" (click)="pickSuggestion(-1)">
|
||||
Create {{ inputValue().trim() }}
|
||||
</button>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
184
src/app/shared/tags-editor/tags-editor.component.ts
Normal file
184
src/app/shared/tags-editor/tags-editor.component.ts
Normal file
@ -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<HTMLElement>);
|
||||
private vault = inject(VaultService);
|
||||
private toast = inject(ToastService);
|
||||
|
||||
@Input() noteId = '';
|
||||
private readonly externalTags = signal<string[]>([]);
|
||||
@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<string[]>();
|
||||
@Output() commit = new EventEmitter<void>();
|
||||
|
||||
readonly _tags = signal<string[]>([]);
|
||||
readonly editing = signal(false);
|
||||
readonly inputValue = signal('');
|
||||
readonly hover = signal(false);
|
||||
readonly focusedChip = signal<number | null>(null);
|
||||
readonly menuOpen = signal(false);
|
||||
readonly menuIndex = signal<number>(-1);
|
||||
readonly highlightedIndex = signal<number | null>(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<string, string>();
|
||||
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());
|
||||
}
|
||||
}
|
||||
3
src/app/shared/toast/index.ts
Normal file
3
src/app/shared/toast/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './toast.model';
|
||||
export * from './toast.service';
|
||||
export * from './toast-container.component';
|
||||
32
src/app/shared/toast/toast-container.component.html
Normal file
32
src/app/shared/toast/toast-container.component.html
Normal file
@ -0,0 +1,32 @@
|
||||
<!-- Container fixe bas-droite -->
|
||||
<div class="pointer-events-none fixed bottom-4 right-4 z-[9999] flex w-[min(92vw,420px)] flex-col gap-2">
|
||||
<div
|
||||
*ngFor="let t of toasts()"
|
||||
class="pointer-events-auto overflow-hidden rounded-2xl border shadow-lg transition-all duration-300 ease-out data-[closing=true]:translate-y-2 data-[closing=true]:opacity-0 bg-slate-800/90 text-slate-50 border-slate-700"
|
||||
[attr.data-closing]="t.closing ? 'true' : 'false'"
|
||||
>
|
||||
<!-- Contenu -->
|
||||
<div class="flex items-start gap-3 p-4">
|
||||
<div class="mt-0.5 text-xl leading-none">
|
||||
<span *ngIf="t.type==='info'">💬</span>
|
||||
<span *ngIf="t.type==='success'">✅</span>
|
||||
<span *ngIf="t.type==='warning'">⚠️</span>
|
||||
<span *ngIf="t.type==='error'">⛔</span>
|
||||
</div>
|
||||
<div class="flex-1 text-sm leading-relaxed">{{ t.message }}</div>
|
||||
<button class="ml-1 rounded-md px-2 py-1 text-xs opacity-70 hover:opacity-100" (click)="dismiss(t.id)" aria-label="Fermer">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Barre de progression en haut -->
|
||||
<div class="relative h-1 w-full bg-transparent">
|
||||
<div class="absolute left-0 top-0 h-1 transition-[width] duration-100 ease-linear"
|
||||
[ngClass]="{
|
||||
'bg-sky-500': t.type==='info',
|
||||
'bg-emerald-500': t.type==='success',
|
||||
'bg-amber-500': t.type==='warning',
|
||||
'bg-rose-500': t.type==='error'
|
||||
}"
|
||||
[style.width.%]="t.progress * 100"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
15
src/app/shared/toast/toast-container.component.ts
Normal file
15
src/app/shared/toast/toast-container.component.ts
Normal file
@ -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); }
|
||||
}
|
||||
11
src/app/shared/toast/toast.model.ts
Normal file
11
src/app/shared/toast/toast.model.ts
Normal file
@ -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
|
||||
}
|
||||
59
src/app/shared/toast/toast.service.ts
Normal file
59
src/app/shared/toast/toast.service.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { Toast } from './toast.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ToastService {
|
||||
readonly toasts = signal<Toast[]>([]);
|
||||
|
||||
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); }
|
||||
}
|
||||
7
src/app/shared/toast/toast.utils.ts
Normal file
7
src/app/shared/toast/toast.utils.ts
Normal file
@ -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 || ''}`;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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 ?? '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user