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)
|
- **Keyboard shortcuts** : Raccourcis clavier (Alt+R, Alt+D)
|
||||||
- **Animations fluides** : 60fps avec optimisations performance
|
- **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
|
### ✏️ Dessins Excalidraw
|
||||||
- **Éditeur intégré** : Ouvrez et modifiez des fichiers `.excalidraw` directement dans l'app
|
- **É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 +)
|
- **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 { input } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import type { Note } from '../../../types';
|
import type { Note } from '../../../types';
|
||||||
import { ScrollableOverlayDirective } from '../../shared/overlay-scrollbar/scrollable-overlay.directive';
|
import { ScrollableOverlayDirective } from '../../shared/overlay-scrollbar/scrollable-overlay.directive';
|
||||||
|
import { TagFilterStore } from '../../core/stores/tag-filter.store';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-notes-list',
|
selector: 'app-notes-list',
|
||||||
@ -10,7 +11,16 @@ import { ScrollableOverlayDirective } from '../../shared/overlay-scrollbar/scrol
|
|||||||
imports: [CommonModule, ScrollableOverlayDirective],
|
imports: [CommonModule, ScrollableOverlayDirective],
|
||||||
template: `
|
template: `
|
||||||
<div class="h-full flex flex-col">
|
<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"
|
<input type="text"
|
||||||
[value]="query()"
|
[value]="query()"
|
||||||
(input)="onQuery($any($event.target).value)"
|
(input)="onQuery($any($event.target).value)"
|
||||||
@ -55,15 +65,26 @@ export class NotesListComponent {
|
|||||||
@Output() openNote = new EventEmitter<string>();
|
@Output() openNote = new EventEmitter<string>();
|
||||||
@Output() queryChange = new EventEmitter<string>();
|
@Output() queryChange = new EventEmitter<string>();
|
||||||
|
|
||||||
|
private store = inject(TagFilterStore);
|
||||||
private q = signal('');
|
private q = signal('');
|
||||||
|
activeTag = signal<string | null>(null);
|
||||||
private syncQuery = effect(() => {
|
private syncQuery = effect(() => {
|
||||||
this.q.set(this.query() || '');
|
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(() => {
|
filtered = computed(() => {
|
||||||
const q = (this.q() || '').toLowerCase().trim();
|
const q = (this.q() || '').toLowerCase().trim();
|
||||||
const folder = (this.folderFilter() || '').toLowerCase().replace(/^\/+|\/+$/g, '');
|
const folder = (this.folderFilter() || '').toLowerCase().replace(/^\/+|\/+$/g, '');
|
||||||
const tag = (this.tagFilter() || '').toLowerCase();
|
const tag = (this.activeTag() || '').toLowerCase();
|
||||||
const quickLink = this.quickLinkFilter();
|
const quickLink = this.quickLinkFilter();
|
||||||
let list = this.notes();
|
let list = this.notes();
|
||||||
|
|
||||||
@ -113,4 +134,12 @@ export class NotesListComponent {
|
|||||||
this.q.set(v);
|
this.q.set(v);
|
||||||
this.queryChange.emit(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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<app-tags-editor class="note-header__tags"
|
<app-tag-manager class="note-header__tags"
|
||||||
[noteId]="noteId"
|
[noteId]="noteId"
|
||||||
[tags]="tags"
|
[tags]="tags"
|
||||||
(tagsUpdated)="tagsChange.emit($event)"
|
(saved)="tagsChange.emit($event)"
|
||||||
></app-tags-editor>
|
></app-tag-manager>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@ -2,12 +2,12 @@ import { AfterViewInit, Component, ElementRef, Input, OnDestroy, Output, EventEm
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { debounceTime, Subject } from 'rxjs';
|
import { debounceTime, Subject } from 'rxjs';
|
||||||
import { splitPathKeepFilename } from '../../../../shared/utils/path';
|
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({
|
@Component({
|
||||||
selector: 'app-note-header',
|
selector: 'app-note-header',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, TagsEditorComponent],
|
imports: [CommonModule, TagManagerComponent],
|
||||||
templateUrl: './note-header.component.html',
|
templateUrl: './note-header.component.html',
|
||||||
styleUrls: ['./note-header.component.scss']
|
styleUrls: ['./note-header.component.scss']
|
||||||
})
|
})
|
||||||
|
|||||||
@ -32,7 +32,7 @@ import { MarkdownPlaygroundComponent } from '../../features/tests/markdown-playg
|
|||||||
[noteHtmlContent]="renderedNoteContent"
|
[noteHtmlContent]="renderedNoteContent"
|
||||||
[allNotes]="vault.allNotes()"
|
[allNotes]="vault.allNotes()"
|
||||||
(noteLinkClicked)="noteSelected.emit($event)"
|
(noteLinkClicked)="noteSelected.emit($event)"
|
||||||
(tagClicked)="tagClicked.emit($event)"
|
(tagClicked)="onTagSelected($event)"
|
||||||
(wikiLinkActivated)="wikiLinkActivated.emit($event)"
|
(wikiLinkActivated)="wikiLinkActivated.emit($event)"
|
||||||
[fullScreenActive]="noteFullScreen"
|
[fullScreenActive]="noteFullScreen"
|
||||||
(fullScreenRequested)="toggleNoteFullScreen()"
|
(fullScreenRequested)="toggleNoteFullScreen()"
|
||||||
@ -127,7 +127,7 @@ import { MarkdownPlaygroundComponent } from '../../features/tests/markdown-playg
|
|||||||
[noteHtmlContent]="renderedNoteContent"
|
[noteHtmlContent]="renderedNoteContent"
|
||||||
[allNotes]="vault.allNotes()"
|
[allNotes]="vault.allNotes()"
|
||||||
(noteLinkClicked)="noteSelected.emit($event)"
|
(noteLinkClicked)="noteSelected.emit($event)"
|
||||||
(tagClicked)="tagClicked.emit($event)"
|
(tagClicked)="onTagSelected($event)"
|
||||||
(wikiLinkActivated)="wikiLinkActivated.emit($event)"
|
(wikiLinkActivated)="wikiLinkActivated.emit($event)"
|
||||||
[fullScreenActive]="noteFullScreen"
|
[fullScreenActive]="noteFullScreen"
|
||||||
(fullScreenRequested)="toggleNoteFullScreen()"
|
(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>
|
<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>
|
||||||
<div [hidden]="mobileNav.activeTab() !== 'page'" class="note-content-area h-full overflow-y-auto px-3 py-4" appScrollableOverlay>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -398,10 +398,20 @@ export class AppShellNimbusLayoutComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onTagSelected(tagName: string) {
|
onTagSelected(tagName: string) {
|
||||||
const norm = (tagName || '').replace(/^#/, '').trim();
|
const norm = (tagName || '').replace(/^#/, '').trim().toLowerCase();
|
||||||
if (!norm) return;
|
if (!norm) return;
|
||||||
this.tagFilter = norm;
|
this.tagFilter = norm;
|
||||||
this.folderFilter = null; // clear folder when focusing tag
|
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()) {
|
if (!this.responsive.isDesktop()) {
|
||||||
this.mobileNav.setActiveTab('list');
|
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 {
|
export class BadgeCountComponent {
|
||||||
@Input() count = 0;
|
@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() {
|
get bgClass() {
|
||||||
return {
|
return {
|
||||||
'bg-slate-600': this.color === 'slate',
|
'bg-slate-600': this.color === 'slate',
|
||||||
'bg-rose-600': this.color === 'rose',
|
'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-indigo-600': this.color === 'indigo',
|
||||||
'bg-emerald-600': this.color === 'emerald',
|
'bg-emerald-600': this.color === 'emerald',
|
||||||
'bg-stone-600': this.color === 'stone',
|
'bg-stone-600': this.color === 'stone',
|
||||||
'bg-zinc-700': this.color === 'zinc',
|
'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
|
creation_date: 2025-10-19T11:13:12-04:00
|
||||||
modification_date: 2025-10-19T12:09:46-04:00
|
modification_date: 2025-10-19T12:09:46-04:00
|
||||||
catégorie: ""
|
catégorie: ""
|
||||||
tags: []
|
tags:
|
||||||
|
- configuration
|
||||||
|
- bruno
|
||||||
|
- markdown
|
||||||
aliases: []
|
aliases: []
|
||||||
status: en-cours
|
status: en-cours
|
||||||
publish: false
|
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
|
# Old Note 2
|
||||||
|
|
||||||
This note is in a subfolder of trash.
|
This note is in a subfolder of trash.
|
||||||
|
|||||||
@ -3,9 +3,13 @@ titre: Nouveau-markdown
|
|||||||
auteur: Bruno Charest
|
auteur: Bruno Charest
|
||||||
creation_date: 2025-10-19T21:42:53-04:00
|
creation_date: 2025-10-19T21:42:53-04:00
|
||||||
modification_date: 2025-10-19T21:43:06-04:00
|
modification_date: 2025-10-19T21:43:06-04:00
|
||||||
catégorie: ""
|
catégorie: markdown
|
||||||
tags: []
|
tags:
|
||||||
aliases: []
|
- test
|
||||||
|
- Bruno
|
||||||
|
- markdown
|
||||||
|
aliases:
|
||||||
|
- nouveau
|
||||||
status: en-cours
|
status: en-cours
|
||||||
publish: true
|
publish: true
|
||||||
favoris: true
|
favoris: true
|
||||||
@ -15,3 +19,25 @@ archive: true
|
|||||||
draft: true
|
draft: true
|
||||||
private: 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
|
creation_date: 2025-10-19T12:15:21-04:00
|
||||||
modification_date: 2025-10-19T12:15:21-04:00
|
modification_date: 2025-10-19T12:15:21-04:00
|
||||||
catégorie: ""
|
catégorie: ""
|
||||||
tags: []
|
tags:
|
||||||
|
- home
|
||||||
|
- accueil
|
||||||
aliases: []
|
aliases: []
|
||||||
status: en-cours
|
status: en-cours
|
||||||
publish: false
|
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
|
creation_date: 2025-10-02T16:10:42-04:00
|
||||||
modification_date: 2025-10-19T12:09:47-04:00
|
modification_date: 2025-10-19T12:09:47-04:00
|
||||||
catégorie: ""
|
catégorie: ""
|
||||||
tags: []
|
tags:
|
||||||
|
- accueil
|
||||||
|
- markdown
|
||||||
|
- bruno
|
||||||
aliases: []
|
aliases: []
|
||||||
status: en-cours
|
status: en-cours
|
||||||
publish: false
|
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
|
tag: testTag
|
||||||
|
tags:
|
||||||
|
- accueil
|
||||||
|
- markdown
|
||||||
|
- bruno
|
||||||
---
|
---
|
||||||
Ceci est la page 1
|
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
|
modification_date: 2025-10-19T12:09:47-04:00
|
||||||
catégorie: ""
|
catégorie: ""
|
||||||
tags:
|
tags:
|
||||||
- tag_metadata_1
|
- tag1
|
||||||
- tag_metadata_2
|
- tag2
|
||||||
- tag_metadata_test
|
- test
|
||||||
|
- test2
|
||||||
|
- home
|
||||||
aliases: []
|
aliases: []
|
||||||
status: en-cours
|
status: en-cours
|
||||||
publish: false
|
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
|
title: Page de test Markdown
|
||||||
tags:
|
|
||||||
- tag_metadata_1
|
|
||||||
- tag_metadata_2
|
|
||||||
- tag_metadata_test
|
|
||||||
created: 2025-09-25T21:20:45-04:00
|
created: 2025-09-25T21:20:45-04:00
|
||||||
modified: 2025-09-25T21:20:45-04:00
|
modified: 2025-09-25T21:20:45-04:00
|
||||||
category: test
|
category: test
|
||||||
@ -14,6 +24,12 @@ number: 12345
|
|||||||
todo: false
|
todo: false
|
||||||
url: https://google.com
|
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
|
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
|
#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