feat: add toast notifications and improve tag management UI
This commit is contained in:
@@ -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">
|
||||
<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>
|
||||
<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>
|
||||
@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>
|
||||
<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>
|
||||
</div>
|
||||
}
|
||||
|
||||
@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>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './toast.model';
|
||||
export * from './toast.service';
|
||||
export * from './toast-container.component';
|
||||
@@ -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>
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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 || ''}`;
|
||||
}
|
||||
Reference in New Issue
Block a user