feat: implement new tag management system with filtering and modern UI overlay
This commit is contained in:
parent
168fcaf049
commit
3c716fab58
32
README.md
32
README.md
@ -65,6 +65,38 @@ ObsiViewer est une application web **Angular 20** moderne et performante qui per
|
||||
- **Keyboard shortcuts** : Raccourcis clavier (Alt+R, Alt+D)
|
||||
- **Animations fluides** : 60fps avec optimisations performance
|
||||
|
||||
---
|
||||
|
||||
## 🧭 Migration UI — Tags (nouveau gestionnaire)
|
||||
|
||||
Depuis cette version, la gestion des tags a été refondue pour une UX claire et performante.
|
||||
|
||||
- **Lecture**: un bouton icône Tag situé à gauche des chips ouvre/ferme l'éditeur. Cliquer un chip applique un filtre sur `NotesList`.
|
||||
- **Édition**: l'éditeur est un overlay moderne avec dé-dupe forte, suggestions et raccourcis (Enter/Tab/Backspace). Aucune fermeture par clic extérieur ou touche ESC.
|
||||
|
||||
### Intégration
|
||||
- Composant lecture/commande: `src/app/shared/tags/tag-manager/tag-manager.component.ts`
|
||||
- Overlay édition: `src/app/shared/tags/tag-editor-overlay/`
|
||||
- Store de filtre: `src/app/core/stores/tag-filter.store.ts`
|
||||
- Utilitaires: `src/app/shared/tags/tag-utils.ts`
|
||||
|
||||
### API composants
|
||||
- `TagManagerComponent`
|
||||
- `@Input() tags: string[]`
|
||||
- `@Input() noteId: string`
|
||||
- `@Output() tagSelected(tag: string)` (lecture)
|
||||
- `@Output() editingChanged(isEditing: boolean)`
|
||||
- `@Output() saved(tags: string[])` (après Enregistrer)
|
||||
|
||||
### Comportements clés
|
||||
- L’édition ne se déclenche QUE via l’icône Tag.
|
||||
- En lecture, cliquer un chip: met à jour `TagFilterStore` et filtre la liste des notes. Un badge “Filtre: #tag” apparaît avec action “Effacer le filtre”.
|
||||
- Sauvegarde des tags via `VaultService.updateNoteTags(noteId, tags)` qui réécrit proprement le frontmatter `tags:`.
|
||||
|
||||
### Tests
|
||||
- `tag-utils.spec.ts` couvre `normalizeTag` et `uniqueTags`.
|
||||
- `tag-manager.component.spec.ts` vérifie l’émission de `tagSelected`.
|
||||
|
||||
### ✏️ Dessins Excalidraw
|
||||
- **Éditeur intégré** : Ouvrez et modifiez des fichiers `.excalidraw` directement dans l'app
|
||||
- **Création rapide** : Bouton "Nouveau dessin" dans l'en-tête (icône +)
|
||||
|
||||
16
src/app/core/stores/tag-filter.store.ts
Normal file
16
src/app/core/stores/tag-filter.store.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TagFilterStore {
|
||||
private _tag = new BehaviorSubject<string | null>(null);
|
||||
readonly selectedTag$: Observable<string | null> = this._tag.asObservable();
|
||||
|
||||
set(tag: string | null): void {
|
||||
this._tag.next(tag);
|
||||
}
|
||||
|
||||
get(): string | null {
|
||||
return this._tag.getValue();
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,9 @@
|
||||
import { Component, EventEmitter, Output, computed, signal, effect } from '@angular/core';
|
||||
import { Component, EventEmitter, Output, computed, signal, effect, inject } from '@angular/core';
|
||||
import { input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import type { Note } from '../../../types';
|
||||
import { ScrollableOverlayDirective } from '../../shared/overlay-scrollbar/scrollable-overlay.directive';
|
||||
import { TagFilterStore } from '../../core/stores/tag-filter.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-notes-list',
|
||||
@ -10,7 +11,16 @@ import { ScrollableOverlayDirective } from '../../shared/overlay-scrollbar/scrol
|
||||
imports: [CommonModule, ScrollableOverlayDirective],
|
||||
template: `
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="p-2 border-b border-gray-200 dark:border-gray-800">
|
||||
<div class="p-2 border-b border-gray-200 dark:border-gray-800 space-y-2">
|
||||
<div *ngIf="activeTag() as t" class="flex items-center gap-2 text-xs">
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-200 px-2 py-1">
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 7h.01"/><path d="M3 7h5a2 2 0 0 1 1.414.586l7 7a2 2 0 0 1 0 2.828l-3.172 3.172a2 2 0 0 1-2.828 0l-7-7A2 2 0 0 1 3 12V7Z"/></svg>
|
||||
Filtre: #{{ t }}
|
||||
</span>
|
||||
<button type="button" (click)="clearTagFilter()" class="rounded-full hover:bg-slate-500/10 dark:hover:bg-slate-200/10 w-6 h-6 inline-flex items-center justify-center" title="Effacer le filtre">
|
||||
<svg class="h-3.5 w-3.5" 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>
|
||||
</div>
|
||||
<input type="text"
|
||||
[value]="query()"
|
||||
(input)="onQuery($any($event.target).value)"
|
||||
@ -55,15 +65,26 @@ export class NotesListComponent {
|
||||
@Output() openNote = new EventEmitter<string>();
|
||||
@Output() queryChange = new EventEmitter<string>();
|
||||
|
||||
private store = inject(TagFilterStore);
|
||||
private q = signal('');
|
||||
activeTag = signal<string | null>(null);
|
||||
private syncQuery = effect(() => {
|
||||
this.q.set(this.query() || '');
|
||||
});
|
||||
private syncTagFromStore = effect(() => {
|
||||
// Prefer explicit input; otherwise, take store value
|
||||
const inputTag = this.tagFilter();
|
||||
if (inputTag !== null && inputTag !== undefined) {
|
||||
this.activeTag.set(inputTag || null);
|
||||
return;
|
||||
}
|
||||
this.activeTag.set(this.store.get());
|
||||
});
|
||||
|
||||
filtered = computed(() => {
|
||||
const q = (this.q() || '').toLowerCase().trim();
|
||||
const folder = (this.folderFilter() || '').toLowerCase().replace(/^\/+|\/+$/g, '');
|
||||
const tag = (this.tagFilter() || '').toLowerCase();
|
||||
const tag = (this.activeTag() || '').toLowerCase();
|
||||
const quickLink = this.quickLinkFilter();
|
||||
let list = this.notes();
|
||||
|
||||
@ -113,4 +134,12 @@ export class NotesListComponent {
|
||||
this.q.set(v);
|
||||
this.queryChange.emit(v);
|
||||
}
|
||||
|
||||
clearTagFilter(): void {
|
||||
// Clear both local input state and store
|
||||
this.activeTag.set(null);
|
||||
if (this.tagFilter() == null) {
|
||||
this.store.set(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,9 +19,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-tags-editor class="note-header__tags"
|
||||
<app-tag-manager class="note-header__tags"
|
||||
[noteId]="noteId"
|
||||
[tags]="tags"
|
||||
(tagsUpdated)="tagsChange.emit($event)"
|
||||
></app-tags-editor>
|
||||
(saved)="tagsChange.emit($event)"
|
||||
></app-tag-manager>
|
||||
</header>
|
||||
|
||||
@ -2,12 +2,12 @@ import { AfterViewInit, Component, ElementRef, Input, OnDestroy, Output, EventEm
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { debounceTime, Subject } from 'rxjs';
|
||||
import { splitPathKeepFilename } from '../../../../shared/utils/path';
|
||||
import { TagsEditorComponent } from '../../../../shared/tags-editor/tags-editor.component';
|
||||
import { TagManagerComponent } from '../../../../shared/tags/tag-manager/tag-manager.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-note-header',
|
||||
standalone: true,
|
||||
imports: [CommonModule, TagsEditorComponent],
|
||||
imports: [CommonModule, TagManagerComponent],
|
||||
templateUrl: './note-header.component.html',
|
||||
styleUrls: ['./note-header.component.scss']
|
||||
})
|
||||
|
||||
@ -32,7 +32,7 @@ import { MarkdownPlaygroundComponent } from '../../features/tests/markdown-playg
|
||||
[noteHtmlContent]="renderedNoteContent"
|
||||
[allNotes]="vault.allNotes()"
|
||||
(noteLinkClicked)="noteSelected.emit($event)"
|
||||
(tagClicked)="tagClicked.emit($event)"
|
||||
(tagClicked)="onTagSelected($event)"
|
||||
(wikiLinkActivated)="wikiLinkActivated.emit($event)"
|
||||
[fullScreenActive]="noteFullScreen"
|
||||
(fullScreenRequested)="toggleNoteFullScreen()"
|
||||
@ -127,7 +127,7 @@ import { MarkdownPlaygroundComponent } from '../../features/tests/markdown-playg
|
||||
[noteHtmlContent]="renderedNoteContent"
|
||||
[allNotes]="vault.allNotes()"
|
||||
(noteLinkClicked)="noteSelected.emit($event)"
|
||||
(tagClicked)="tagClicked.emit($event)"
|
||||
(tagClicked)="onTagSelected($event)"
|
||||
(wikiLinkActivated)="wikiLinkActivated.emit($event)"
|
||||
[fullScreenActive]="noteFullScreen"
|
||||
(fullScreenRequested)="toggleNoteFullScreen()"
|
||||
@ -165,7 +165,7 @@ import { MarkdownPlaygroundComponent } from '../../features/tests/markdown-playg
|
||||
<app-notes-list [notes]="vault.allNotes()" [folderFilter]="folderFilter" [tagFilter]="tagFilter" [quickLinkFilter]="quickLinkFilter" [query]="listQuery" (queryChange)="listQuery=$event" (openNote)="onOpenNote($event)"></app-notes-list>
|
||||
</div>
|
||||
<div [hidden]="mobileNav.activeTab() !== 'page'" class="note-content-area h-full overflow-y-auto px-3 py-4" appScrollableOverlay>
|
||||
<app-note-viewer [note]="selectedNote || null" [noteHtmlContent]="renderedNoteContent" [allNotes]="vault.allNotes()" (noteLinkClicked)="noteSelected.emit($event)" (tagClicked)="tagClicked.emit($event)" (wikiLinkActivated)="wikiLinkActivated.emit($event)" [fullScreenActive]="noteFullScreen" (fullScreenRequested)="toggleNoteFullScreen()" (legacyRequested)="ui.toggleUIMode()" (showToc)="mobileNav.toggleToc()" (directoryClicked)="onFolderSelected($event)" [tocOpen]="mobileNav.tocOpen()"></app-note-viewer>
|
||||
<app-note-viewer [note]="selectedNote || null" [noteHtmlContent]="renderedNoteContent" [allNotes]="vault.allNotes()" (noteLinkClicked)="noteSelected.emit($event)" (tagClicked)="onTagSelected($event)" (wikiLinkActivated)="wikiLinkActivated.emit($event)" [fullScreenActive]="noteFullScreen" (fullScreenRequested)="toggleNoteFullScreen()" (legacyRequested)="ui.toggleUIMode()" (showToc)="mobileNav.toggleToc()" (directoryClicked)="onFolderSelected($event)" [tocOpen]="mobileNav.tocOpen()"></app-note-viewer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -398,10 +398,20 @@ export class AppShellNimbusLayoutComponent {
|
||||
}
|
||||
|
||||
onTagSelected(tagName: string) {
|
||||
const norm = (tagName || '').replace(/^#/, '').trim();
|
||||
const norm = (tagName || '').replace(/^#/, '').trim().toLowerCase();
|
||||
if (!norm) return;
|
||||
this.tagFilter = norm;
|
||||
this.folderFilter = null; // clear folder when focusing tag
|
||||
// Clear other filters and search to focus on tag results
|
||||
this.quickLinkFilter = null;
|
||||
this.listQuery = '';
|
||||
// Ensure the list is visible: exit fullscreen if active
|
||||
if (this.noteFullScreen) {
|
||||
this.noteFullScreen = false;
|
||||
document.body.classList.remove('note-fullscreen-active');
|
||||
}
|
||||
// Bubble up for global handlers (keeps parity with right sidebar tags)
|
||||
this.tagClicked.emit(norm);
|
||||
if (!this.responsive.isDesktop()) {
|
||||
this.mobileNav.setActiveTab('list');
|
||||
}
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
/* Overlay styling relies on Tailwind utilities in the template. Keep minimal spacing fixes here if needed. */
|
||||
:host { display: contents; }
|
||||
@ -0,0 +1,50 @@
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/40"></div>
|
||||
<div class="relative rounded-2xl shadow-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 p-4 md:p-5 w-[min(780px,92vw)]">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="h-5 w-5 text-slate-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 7h.01"/><path d="M3 7h5a2 2 0 0 1 1.414.586l7 7a2 2 0 0 1 0 2.828l-3.172 3.172a2 2 0 0 1-2.828 0l-7-7A2 2 0 0 1 3 12V7Z"/></svg>
|
||||
<h3 class="text-base font-semibold">Éditer les tags</h3>
|
||||
<span class="text-sm text-slate-500">({{ count() }})</span>
|
||||
</div>
|
||||
<button type="button" class="inline-flex items-center justify-center w-8 h-8 rounded-full hover:bg-slate-500/10 dark:hover:bg-slate-200/10" (click)="close.emit()" aria-label="Fermer">
|
||||
<svg class="h-4 w-4" 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>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-wrap gap-2 rounded-xl border border-slate-200 dark:border-slate-700 p-3 min-h-[44px]">
|
||||
@for (t of working(); track t) {
|
||||
<span class="inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-sm font-medium bg-slate-100 dark:bg-slate-800">
|
||||
{{ t }}
|
||||
<button type="button" class="w-6 h-6 inline-flex items-center justify-center rounded-full hover:bg-slate-500/10 dark:hover:bg-slate-200/10" (click)="removeTag(t)" aria-label="Retirer">
|
||||
<svg class="h-3.5 w-3.5" 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>
|
||||
}
|
||||
<input data-tag-input type="text" [value]="inputValue()" (input)="inputValue.set($any($event.target).value)" (keydown)="onKeydown($event)" placeholder="Ajouter un tag..." class="min-w-[200px] flex-1 bg-transparent outline-none px-2 py-1.5" />
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
@if (suggestions().length === 0) {
|
||||
<div class="px-3 py-2 text-sm text-slate-500">Aucune suggestion</div>
|
||||
} @else {
|
||||
<ul>
|
||||
@for (s of suggestions(); track s) {
|
||||
<li>
|
||||
<button type="button" class="w-full text-left px-3 py-2 hover:bg-slate-50 dark:hover:bg-slate-800" (click)="pickSuggestion(s)">{{ s }}</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 pt-1">
|
||||
<button type="button" class="px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800" (click)="close.emit()">Annuler</button>
|
||||
<button type="button" class="px-3 py-1.5 rounded-lg bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-60" [disabled]="saving()" (click)="save()">Enregistrer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,114 @@
|
||||
import { Component, ChangeDetectionStrategy, Input, Output, EventEmitter, inject, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { uniqueTags, normalizeTag } from '../tag-utils';
|
||||
import { VaultService } from '../../../../services/vault.service';
|
||||
import { ToastService } from '../../toast/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-tag-editor-overlay',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './tag-editor-overlay.component.html',
|
||||
styleUrls: ['./tag-editor-overlay.component.css'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TagEditorOverlayComponent {
|
||||
private vault = inject(VaultService);
|
||||
private toast = inject(ToastService);
|
||||
|
||||
@Input() value: string[] = [];
|
||||
@Input() allTags: string[] = [];
|
||||
@Input() noteId = '';
|
||||
|
||||
@Output() saved = new EventEmitter<string[]>();
|
||||
@Output() close = new EventEmitter<void>();
|
||||
|
||||
inputValue = signal('');
|
||||
saving = signal(false);
|
||||
working = signal<string[]>([]);
|
||||
count = computed(() => this.working().length);
|
||||
|
||||
ngOnInit() {
|
||||
this.working.set(uniqueTags(this.value || []));
|
||||
}
|
||||
|
||||
private allKnownTags = computed(() => {
|
||||
const provided = this.allTags || [];
|
||||
if (provided.length > 0) return provided;
|
||||
try {
|
||||
return this.vault.tags().map(t => t.name);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
suggestions = computed(() => {
|
||||
const q = this.inputValue().trim().toLowerCase();
|
||||
const source = this.allKnownTags();
|
||||
const pool = q ? source.filter(t => t.toLowerCase().includes(q)) : source;
|
||||
const exist = new Set(this.working().map(t => t.toLowerCase()));
|
||||
return pool.filter(t => !exist.has(t.toLowerCase())).slice(0, 50);
|
||||
});
|
||||
|
||||
onKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
this.addFromInput();
|
||||
} else if (ev.key === 'Tab') {
|
||||
const s = this.suggestions();
|
||||
if (s.length) {
|
||||
ev.preventDefault();
|
||||
this.addTag(s[0]);
|
||||
}
|
||||
} else if (ev.key === 'Backspace' && !this.inputValue()) {
|
||||
ev.preventDefault();
|
||||
const cur = this.working();
|
||||
if (cur.length) this.removeTag(cur[cur.length - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
addFromInput() {
|
||||
const raw = this.inputValue().trim();
|
||||
if (!raw) return;
|
||||
this.addTag(raw);
|
||||
}
|
||||
|
||||
addTag(raw: string) {
|
||||
const t = normalizeTag(raw);
|
||||
if (!t) return;
|
||||
if (this.working().some(x => x.toLowerCase() === t.toLowerCase())) return;
|
||||
this.working.update(arr => [...arr, t]);
|
||||
this.inputValue.set('');
|
||||
}
|
||||
|
||||
pickSuggestion(s: string) {
|
||||
this.addTag(s);
|
||||
}
|
||||
|
||||
removeTag(tag: string) {
|
||||
this.working.update(arr => arr.filter(t => t.toLowerCase() !== tag.toLowerCase()));
|
||||
}
|
||||
|
||||
async save() {
|
||||
const tags = uniqueTags(this.working());
|
||||
if (!this.noteId) {
|
||||
this.saved.emit(tags);
|
||||
this.close.emit();
|
||||
return;
|
||||
}
|
||||
this.saving.set(true);
|
||||
try {
|
||||
const ok = await this.vault.updateNoteTags(this.noteId, tags);
|
||||
if (ok) {
|
||||
this.toast.success('✅ Tags mis à jour');
|
||||
this.saved.emit(tags);
|
||||
} else {
|
||||
this.toast.error('❌ Échec de la mise à jour des tags');
|
||||
}
|
||||
} catch {
|
||||
this.toast.error('❌ Échec de la mise à jour des tags');
|
||||
} finally {
|
||||
this.saving.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
/* Styles are provided via Tailwind utility classes directly in templates. */
|
||||
31
src/app/shared/tags/tag-manager/tag-manager.component.html
Normal file
31
src/app/shared/tags/tag-manager/tag-manager.component.html
Normal file
@ -0,0 +1,31 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center w-9 h-9 md:w-9 md:h-9 sm:w-8 sm:h-8 rounded-full hover:bg-slate-500/10 dark:hover:bg-slate-200/10"
|
||||
aria-label="Modifier les tags"
|
||||
[attr.aria-pressed]="isEditing()"
|
||||
(click)="toggleEditor()"
|
||||
title="Modifier les tags">
|
||||
<svg class="text-xl" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 7h.01"/><path d="M3 7h5a2 2 0 0 1 1.414.586l7 7a2 2 0 0 1 0 2.828l-3.172 3.172a2 2 0 0 1-2.828 0l-7-7A2 2 0 0 1 3 12V7Z"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-1.5 rounded-full bg-slate-700/10 dark:bg-slate-300/10 text-sm font-medium hover:bg-slate-500/10 dark:hover:bg-slate-200/10"
|
||||
*ngFor="let tag of normalizedTags()"
|
||||
(click)="onChipClick(tag)"
|
||||
[title]="'Voir les notes #'+tag">
|
||||
{{ tag }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-tag-editor-overlay
|
||||
*ngIf="isEditing()"
|
||||
[value]="normalizedTags()"
|
||||
[noteId]="noteId"
|
||||
[allTags]="allTags || []"
|
||||
(close)="onClose()"
|
||||
(saved)="onSaved($event)"
|
||||
></app-tag-editor-overlay>
|
||||
@ -0,0 +1,37 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TagManagerComponent } from './tag-manager.component';
|
||||
|
||||
describe('TagManagerComponent', () => {
|
||||
let component: TagManagerComponent;
|
||||
let fixture: ComponentFixture<TagManagerComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TagManagerComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(TagManagerComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.tags = ['tag1', 'tag2'];
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('emits tagSelected when chip clicked in read mode', () => {
|
||||
const spy = jasmine.createSpy('tagSelected');
|
||||
component.tagSelected.subscribe(spy);
|
||||
|
||||
component.onChipClick('tag1');
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('tag1');
|
||||
});
|
||||
|
||||
it('does not emit when editing', () => {
|
||||
const spy = jasmine.createSpy('tagSelected');
|
||||
component.tagSelected.subscribe(spy);
|
||||
component.toggleEditor();
|
||||
|
||||
component.onChipClick('tag1');
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
59
src/app/shared/tags/tag-manager/tag-manager.component.ts
Normal file
59
src/app/shared/tags/tag-manager/tag-manager.component.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { Component, ChangeDetectionStrategy, Input, Output, EventEmitter, inject, signal, computed } from '@angular/core';
|
||||
import { CommonModule, NgFor, NgIf } from '@angular/common';
|
||||
import { TagEditorOverlayComponent } from '../tag-editor-overlay/tag-editor-overlay.component';
|
||||
import { TagFilterStore } from '../../../core/stores/tag-filter.store';
|
||||
import { uniqueTags } from '../tag-utils';
|
||||
|
||||
@Component({
|
||||
selector: 'app-tag-manager',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgFor, NgIf, TagEditorOverlayComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './tag-manager.component.html',
|
||||
styleUrls: ['./tag-manager.component.css']
|
||||
})
|
||||
export class TagManagerComponent {
|
||||
private store = inject(TagFilterStore);
|
||||
|
||||
private readonly tagsSignal = signal<string[]>([]);
|
||||
@Input() set tags(value: string[] | null | undefined) {
|
||||
this.tagsSignal.set(value ? [...value] : []);
|
||||
}
|
||||
get tags(): string[] {
|
||||
return this.tagsSignal();
|
||||
}
|
||||
@Input() allTags: string[] | null = null; // optional preloaded
|
||||
@Input() noteId = '';
|
||||
|
||||
@Output() tagSelected = new EventEmitter<string>();
|
||||
@Output() editingChanged = new EventEmitter<boolean>();
|
||||
@Output() saved = new EventEmitter<string[]>();
|
||||
|
||||
isEditing = signal(false);
|
||||
readonly normalizedTags = computed(() => uniqueTags(this.tagsSignal()));
|
||||
|
||||
toggleEditor(): void {
|
||||
const next = !this.isEditing();
|
||||
this.isEditing.set(next);
|
||||
this.editingChanged.emit(next);
|
||||
}
|
||||
|
||||
onChipClick(tag: string): void {
|
||||
if (this.isEditing()) return; // read-mode only
|
||||
this.store.set(tag);
|
||||
this.tagSelected.emit(tag);
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
if (!this.isEditing()) return;
|
||||
this.isEditing.set(false);
|
||||
this.editingChanged.emit(false);
|
||||
}
|
||||
|
||||
onSaved(next: string[]): void {
|
||||
this.saved.emit(next);
|
||||
this.tagsSignal.set(next);
|
||||
this.isEditing.set(false);
|
||||
this.editingChanged.emit(false);
|
||||
}
|
||||
}
|
||||
13
src/app/shared/tags/tag-utils.spec.ts
Normal file
13
src/app/shared/tags/tag-utils.spec.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { uniqueTags, normalizeTag } from './tag-utils';
|
||||
|
||||
describe('tag-utils', () => {
|
||||
it('normalizeTag trims, replaces spaces with underscore, strips invalids, lowercases', () => {
|
||||
expect(normalizeTag(' Hello World ')).toBe('hello_world');
|
||||
expect(normalizeTag('A/B C')).toBe('ab_c');
|
||||
expect(normalizeTag('Déjà Vu!')).toBe('dj_vu');
|
||||
});
|
||||
|
||||
it('uniqueTags removes duplicates case-insensitive and empties', () => {
|
||||
expect(uniqueTags(['Tag', 'tag', ' other ', 'OTHER', '', ' '])).toEqual(['tag', 'other']);
|
||||
});
|
||||
});
|
||||
19
src/app/shared/tags/tag-utils.ts
Normal file
19
src/app/shared/tags/tag-utils.ts
Normal file
@ -0,0 +1,19 @@
|
||||
export const normalizeTag = (s: string) =>
|
||||
(s ?? '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/[^\w-]/g, '')
|
||||
.toLowerCase();
|
||||
|
||||
export const uniqueTags = (arr: string[]) => {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const raw of arr ?? []) {
|
||||
const t = normalizeTag(String(raw ?? ''));
|
||||
if (t && !seen.has(t)) {
|
||||
seen.add(t);
|
||||
out.push(t);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
@ -17,17 +17,19 @@ import { CommonModule } from '@angular/common';
|
||||
})
|
||||
export class BadgeCountComponent {
|
||||
@Input() count = 0;
|
||||
@Input() color: 'slate'|'rose'|'amber'|'indigo'|'emerald'|'stone'|'zinc' = 'slate';
|
||||
@Input() color: 'slate'|'rose'|'amber'|'indigo'|'emerald'|'stone'|'zinc'|'green'|'purple' = 'slate';
|
||||
|
||||
get bgClass() {
|
||||
return {
|
||||
'bg-slate-600': this.color === 'slate',
|
||||
'bg-rose-600': this.color === 'rose',
|
||||
'bg-amber-600': this.color === 'amber',
|
||||
'bg-sky-400': this.color === 'amber',
|
||||
'bg-indigo-600': this.color === 'indigo',
|
||||
'bg-emerald-600': this.color === 'emerald',
|
||||
'bg-stone-600': this.color === 'stone',
|
||||
'bg-zinc-700': this.color === 'zinc',
|
||||
'bg-green-500': this.color === 'green',
|
||||
'bg-purple-500': this.color === 'purple',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,10 @@ auteur: Bruno Charest
|
||||
creation_date: 2025-10-19T11:13:12-04:00
|
||||
modification_date: 2025-10-19T12:09:46-04:00
|
||||
catégorie: ""
|
||||
tags: []
|
||||
tags:
|
||||
- configuration
|
||||
- bruno
|
||||
- markdown
|
||||
aliases: []
|
||||
status: en-cours
|
||||
publish: false
|
||||
|
||||
@ -1,3 +1,23 @@
|
||||
---
|
||||
titre: old-note-2
|
||||
auteur: Bruno Charest
|
||||
creation_date: 2025-10-19T11:13:12-04:00
|
||||
modification_date: 2025-10-19T12:09:46-04:00
|
||||
catégorie: ""
|
||||
aliases: []
|
||||
status: en-cours
|
||||
publish: false
|
||||
favoris: false
|
||||
template: false
|
||||
task: false
|
||||
archive: false
|
||||
draft: false
|
||||
private: false
|
||||
tags:
|
||||
- configuration
|
||||
- bruno
|
||||
- markdown
|
||||
---
|
||||
# Old Note 2
|
||||
|
||||
This note is in a subfolder of trash.
|
||||
|
||||
@ -3,9 +3,13 @@ titre: Nouveau-markdown
|
||||
auteur: Bruno Charest
|
||||
creation_date: 2025-10-19T21:42:53-04:00
|
||||
modification_date: 2025-10-19T21:43:06-04:00
|
||||
catégorie: ""
|
||||
tags: []
|
||||
aliases: []
|
||||
catégorie: markdown
|
||||
tags:
|
||||
- test
|
||||
- Bruno
|
||||
- markdown
|
||||
aliases:
|
||||
- nouveau
|
||||
status: en-cours
|
||||
publish: true
|
||||
favoris: true
|
||||
@ -15,3 +19,25 @@ archive: true
|
||||
draft: true
|
||||
private: true
|
||||
---
|
||||
# Nouveau-markdown
|
||||
|
||||
#tag1 #tag2 #tag3
|
||||
|
||||
## sous-titre
|
||||
|
||||
## sous-titre 2
|
||||
|
||||
## sous-titre 3
|
||||
|
||||
## sous-titre 4
|
||||
|
||||
## sous-titre 5
|
||||
|
||||
## sous-titre 6
|
||||
|
||||
## sous-titre 7
|
||||
|
||||
## sous-titre 8
|
||||
|
||||
|
||||
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
---
|
||||
titre: Nouveau-markdown
|
||||
auteur: Bruno Charest
|
||||
creation_date: 2025-10-19T21:42:53-04:00
|
||||
modification_date: 2025-10-19T21:43:06-04:00
|
||||
catégorie: markdown
|
||||
tags:
|
||||
- test
|
||||
- Bruno
|
||||
- markdown
|
||||
aliases:
|
||||
- nouveau
|
||||
status: en-cours
|
||||
publish: true
|
||||
favoris: true
|
||||
template: true
|
||||
task: true
|
||||
archive: true
|
||||
draft: true
|
||||
private: true
|
||||
---
|
||||
|
||||
# Nouveau-markdown
|
||||
|
||||
#tag1 #tag2 #tag3
|
||||
|
||||
## sous-titre
|
||||
|
||||
@ -4,7 +4,9 @@ auteur: Bruno Charest
|
||||
creation_date: 2025-10-19T12:15:21-04:00
|
||||
modification_date: 2025-10-19T12:15:21-04:00
|
||||
catégorie: ""
|
||||
tags: []
|
||||
tags:
|
||||
- home
|
||||
- accueil
|
||||
aliases: []
|
||||
status: en-cours
|
||||
publish: false
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
---
|
||||
titre: test-new-file
|
||||
auteur: Bruno Charest
|
||||
creation_date: 2025-10-19T12:15:21-04:00
|
||||
modification_date: 2025-10-19T12:15:21-04:00
|
||||
catégorie: ""
|
||||
aliases: []
|
||||
status: en-cours
|
||||
publish: false
|
||||
favoris: false
|
||||
template: false
|
||||
task: false
|
||||
archive: false
|
||||
draft: false
|
||||
private: false
|
||||
tags:
|
||||
- home
|
||||
- accueil
|
||||
---
|
||||
@ -4,7 +4,10 @@ auteur: Bruno Charest
|
||||
creation_date: 2025-10-02T16:10:42-04:00
|
||||
modification_date: 2025-10-19T12:09:47-04:00
|
||||
catégorie: ""
|
||||
tags: []
|
||||
tags:
|
||||
- accueil
|
||||
- markdown
|
||||
- bruno
|
||||
aliases: []
|
||||
status: en-cours
|
||||
publish: false
|
||||
|
||||
@ -1,4 +1,22 @@
|
||||
---
|
||||
titre: test2
|
||||
auteur: Bruno Charest
|
||||
creation_date: 2025-10-02T16:10:42-04:00
|
||||
modification_date: 2025-10-19T12:09:47-04:00
|
||||
catégorie: ""
|
||||
aliases: []
|
||||
status: en-cours
|
||||
publish: false
|
||||
favoris: true
|
||||
template: false
|
||||
task: false
|
||||
archive: false
|
||||
draft: false
|
||||
private: false
|
||||
tag: testTag
|
||||
tags:
|
||||
- accueil
|
||||
- markdown
|
||||
- bruno
|
||||
---
|
||||
Ceci est la page 1
|
||||
@ -5,9 +5,11 @@ creation_date: 2025-09-25T07:45:20-04:00
|
||||
modification_date: 2025-10-19T12:09:47-04:00
|
||||
catégorie: ""
|
||||
tags:
|
||||
- tag_metadata_1
|
||||
- tag_metadata_2
|
||||
- tag_metadata_test
|
||||
- tag1
|
||||
- tag2
|
||||
- test
|
||||
- test2
|
||||
- home
|
||||
aliases: []
|
||||
status: en-cours
|
||||
publish: false
|
||||
|
||||
@ -1,9 +1,19 @@
|
||||
---
|
||||
titre: test
|
||||
auteur: Bruno Charest
|
||||
creation_date: 2025-09-25T07:45:20-04:00
|
||||
modification_date: 2025-10-19T12:09:47-04:00
|
||||
catégorie: ""
|
||||
aliases: []
|
||||
status: en-cours
|
||||
publish: false
|
||||
favoris: false
|
||||
template: false
|
||||
task: false
|
||||
archive: false
|
||||
draft: false
|
||||
private: false
|
||||
title: Page de test Markdown
|
||||
tags:
|
||||
- tag_metadata_1
|
||||
- tag_metadata_2
|
||||
- tag_metadata_test
|
||||
created: 2025-09-25T21:20:45-04:00
|
||||
modified: 2025-09-25T21:20:45-04:00
|
||||
category: test
|
||||
@ -14,6 +24,12 @@ number: 12345
|
||||
todo: false
|
||||
url: https://google.com
|
||||
image: https://images.unsplash.com/photo-1675789652575-0a5d2425b6c2?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2070&q=80
|
||||
tags:
|
||||
- tag1
|
||||
- tag2
|
||||
- test
|
||||
- test2
|
||||
- home
|
||||
---
|
||||
#tag1 #tag2 #test #test2
|
||||
|
||||
|
||||
17
vault/totoTata.md
Normal file
17
vault/totoTata.md
Normal file
@ -0,0 +1,17 @@
|
||||
---
|
||||
titre: totoTata
|
||||
auteur: Bruno Charest
|
||||
creation_date: 2025-10-20T10:11:39-04:00
|
||||
modification_date: 2025-10-20T10:11:39-04:00
|
||||
catégorie: ""
|
||||
tags: []
|
||||
aliases: []
|
||||
status: en-cours
|
||||
publish: false
|
||||
favoris: true
|
||||
template: false
|
||||
task: true
|
||||
archive: false
|
||||
draft: false
|
||||
private: false
|
||||
---
|
||||
0
vault/totoTata.md.bak
Normal file
0
vault/totoTata.md.bak
Normal file
Loading…
x
Reference in New Issue
Block a user