feat: enhance note list views with rich metadata display

- Added timestamp, author, and description fields to both compact and detailed view modes
- Implemented thumbnail support in detailed view with automatic image extraction from note content
- Improved auto-selection behavior when filters change using reactive signals for more reliable note navigation
This commit is contained in:
Bruno Charest 2025-11-04 13:14:51 -05:00
parent 7331077ffa
commit 7be20d05e0
6 changed files with 272 additions and 65 deletions

View File

@ -192,21 +192,26 @@ import { AIToolsService } from '../../services/ai-tools.service';
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="title text-sm truncate">{{ n.title }}</div> <div class="title text-sm truncate">{{ n.title }}</div>
<div class="meta text-xs truncate">{{ n.filePath }}</div> <div class="meta text-xs truncate">{{ n.filePath }}</div>
<div *ngIf="getUpdatedTimestamp(n) as ts" class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">🕓 {{ formatDateTime(ts) }}</div>
</div> </div>
</div> </div>
<!-- Detailed View --> <!-- Detailed View -->
<div *ngIf="state.viewMode() === 'detailed'" class="note-inner flex items-start gap-2 space-y-0"> <div *ngIf="state.viewMode() === 'detailed'" class="note-inner flex flex-col sm:flex-row items-start gap-4">
<div class="flex items-start gap-2 min-w-0 flex-1">
<span class="note-color-dot flex-shrink-0 mt-1" [style.backgroundColor]="getNoteColor(n)" aria-hidden="true"></span> <span class="note-color-dot flex-shrink-0 mt-1" [style.backgroundColor]="getNoteColor(n)" aria-hidden="true"></span>
<span class="flex-shrink-0 mt-0.5" title="Type">{{ typeIcon(n) }}</span> <span class="flex-shrink-0 mt-0.5" title="Type">{{ typeIcon(n) }}</span>
<div class="min-w-0 flex-1 space-y-1.5"> <div class="min-w-0 flex-1 space-y-1.5">
<div class="title text-sm truncate">{{ n.title }}</div> <div class="title text-sm truncate">{{ n.title }}</div>
<div class="meta text-xs truncate">{{ n.filePath }}</div> <div class="meta text-xs truncate">{{ n.filePath }}</div>
<div class="excerpt text-xs"> <div *ngIf="getAuthor(n) as a" class="text-xs text-gray-500 dark:text-gray-400 truncate"> {{ a }}</div>
<span *ngIf="n.frontmatter?.status">Status: {{ n.frontmatter.status }}</span> <div *ngIf="getDescription(n) as d" class="text-xs text-muted truncate">{{ d }}</div>
<span *ngIf="n.mtime" class="ml-2">{{ formatDate(n.mtime) }}</span> <div *ngIf="getUpdatedTimestamp(n) as ts" class="text-xs text-gray-500 dark:text-gray-400">🕓 {{ formatDateTime(ts) }}</div>
</div> </div>
</div> </div>
<div class="flex-shrink-0 sm:ml-4 mt-2 sm:mt-0" *ngIf="getThumbnailSrc(n) as imgSrc">
<img [src]="imgSrc" alt="" class="rounded-md shadow-sm object-cover w-24 h-24" />
</div>
</div> </div>
</li> </li>
</ul> </ul>
@ -1049,6 +1054,75 @@ export class NotesListComponent {
} }
} }
getUpdatedTimestamp(n: Note): number | null {
try {
const m = Number(n.mtime || 0);
if (m && !Number.isNaN(m)) return m;
} catch {}
const parse = (s?: string) => {
if (!s) return 0;
const t = Date.parse(s);
return Number.isFinite(t) ? t : 0;
};
const u = parse(n.updatedAt);
if (u) return u;
const c = parse(n.createdAt);
return c || null;
}
formatDateTime(ts: number): string {
try {
const d = new Date(ts);
return d.toLocaleString('fr-FR', { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' });
} catch { return ''; }
}
getAuthor(n: Note): string | null {
const fm = (n.frontmatter || {}) as any;
const v = (fm.author ?? fm.auteur ?? n.author) as any;
return (typeof v === 'string' && v.trim().length > 0) ? v : null;
}
getDescription(n: Note): string | null {
const fm = (n.frontmatter || {}) as any;
const v = (fm.description ?? fm.desc) as any;
return (typeof v === 'string' && v.trim().length > 0) ? v : null;
}
private extractFirstImageFromContent(n: Note): string | null {
const content = (n.rawContent ?? n.content ?? '').toString();
if (!content) return null;
try {
const embed = content.match(/!\[\[(.*?)\]\]/);
if (embed && embed[1]) {
const name = embed[1].trim();
if (name) {
const notePath = (n.filePath || n.originalPath || '').replace(/\\/g, '/');
return `/api/attachments/resolve?name=${encodeURIComponent(name)}&note=${encodeURIComponent(notePath)}`;
}
}
const md = content.match(/!\[[^\]]*\]\(([^\)\s]+)(?:\s+\"[^\"]*\")?\)/);
if (md && md[1]) {
const p = md[1].trim();
if (/^(data:|https?:)/i.test(p)) return p;
if (p.startsWith('/')) return `/vault/${encodeURI(p.replace(/^\/+/, ''))}`;
const notePath = (n.filePath || n.originalPath || '').replace(/\\/g, '/');
return `/api/attachments/resolve?name=${encodeURIComponent(p)}&note=${encodeURIComponent(notePath)}`;
}
} catch {}
return null;
}
getThumbnailSrc(n: Note): string | null {
try {
const kind = this.fileTypes.getViewerType(n.filePath, n.rawContent ?? n.content);
if (kind === 'image') {
return `/vault/${encodeURI(n.filePath)}`;
}
} catch {}
return this.extractFirstImageFromContent(n);
}
// ============ Multiple Selection Methods ============ // ============ Multiple Selection Methods ============
/** /**

View File

@ -138,16 +138,25 @@ import { takeUntil } from 'rxjs/operators';
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="title text-sm truncate">{{ note.title }}</div> <div class="title text-sm truncate">{{ note.title }}</div>
<div class="meta text-xs truncate">{{ note.filePath }}</div> <div class="meta text-xs truncate">{{ note.filePath }}</div>
<div *ngIf="getUpdatedTimestampById(note) as ts" class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">🕓 {{ formatDateTime(ts) }}</div>
</div> </div>
</div> </div>
<!-- Detailed View --> <!-- Detailed View -->
<div *ngIf="state.viewMode() === 'detailed'" class="note-inner flex items-start gap-2 space-y-0"> <div *ngIf="state.viewMode() === 'detailed'" class="note-inner flex flex-col sm:flex-row items-start gap-4">
<div class="flex items-start gap-2 min-w-0 flex-1">
<span class="note-color-dot flex-shrink-0 mt-1" [style.backgroundColor]="getNoteColorById(note.id)" aria-hidden="true"></span> <span class="note-color-dot flex-shrink-0 mt-1" [style.backgroundColor]="getNoteColorById(note.id)" aria-hidden="true"></span>
<span class="flex-shrink-0 mt-0.5" title="Type">{{ typeIcon(note.filePath) }}</span> <span class="flex-shrink-0 mt-0.5" title="Type">{{ typeIcon(note.filePath) }}</span>
<div class="min-w-0 flex-1 space-y-1.5"> <div class="min-w-0 flex-1 space-y-1.5">
<div class="title text-sm truncate">{{ note.title }}</div> <div class="title text-sm truncate">{{ note.title }}</div>
<div class="meta text-xs truncate">{{ note.filePath }}</div> <div class="meta text-xs truncate">{{ note.filePath }}</div>
<div *ngIf="getAuthorById(note.id) as a" class="text-xs text-gray-500 dark:text-gray-400 truncate"> {{ a }}</div>
<div *ngIf="getDescriptionById(note.id) as d" class="text-xs text-muted truncate">{{ d }}</div>
<div *ngIf="getUpdatedTimestampById(note) as ts" class="text-xs text-gray-500 dark:text-gray-400">🕓 {{ formatDateTime(ts) }}</div>
</div>
</div>
<div class="flex-shrink-0 sm:ml-4 mt-2 sm:mt-0" *ngIf="getThumbnailSrcById(note) as imgSrc">
<img [src]="imgSrc" alt="" class="rounded-md shadow-sm object-cover w-24 h-24" />
</div> </div>
</div> </div>
</li> </li>
@ -1030,6 +1039,82 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
} catch { return '📎'; } } catch { return '📎'; }
} }
// Metadata helpers used by Comfortable/Detailed modes
getUpdatedTimestampById(meta: NoteMetadata): number | null {
try {
const full = this.getFullNoteById(meta.id) as any;
const m = Number(full?.mtime || 0);
if (m && !Number.isNaN(m)) return m;
} catch {}
const parse = (s?: string) => {
if (!s) return 0;
const t = Date.parse(s);
return Number.isFinite(t) ? t : 0;
};
const u = parse(meta.updatedAt);
if (u) return u;
const c = parse(meta.createdAt);
return c || null;
}
formatDateTime(ts: number): string {
try {
const d = new Date(ts);
return d.toLocaleString('fr-FR', { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' });
} catch { return ''; }
}
getAuthorById(id: string): string | null {
const full = this.getFullNoteById(id) as any;
const fm = (full?.frontmatter || {}) as any;
const v = (fm.author ?? fm.auteur ?? full?.author) as any;
return (typeof v === 'string' && v.trim().length > 0) ? v : null;
}
getDescriptionById(id: string): string | null {
const full = this.getFullNoteById(id) as any;
const fm = (full?.frontmatter || {}) as any;
const v = (fm.description ?? fm.desc) as any;
return (typeof v === 'string' && v.trim().length > 0) ? v : null;
}
private extractFirstImageFromFull(full: any): string | null {
if (!full) return null;
const content = String(full.rawContent ?? full.content ?? '');
if (!content) return null;
try {
const embed = content.match(/!\[\[(.*?)\]\]/);
if (embed && embed[1]) {
const name = embed[1].trim();
if (name) {
const notePath = String(full.filePath || full.originalPath || '').replace(/\\/g, '/');
return `/api/attachments/resolve?name=${encodeURIComponent(name)}&note=${encodeURIComponent(notePath)}`;
}
}
const md = content.match(/!\[[^\]]*\]\(([^\)\s]+)(?:\s+\"[^\"]*\")?\)/);
if (md && md[1]) {
const p = md[1].trim();
if (/^(data:|https?:)/i.test(p)) return p;
if (p.startsWith('/')) return `/vault/${encodeURI(p.replace(/^\/+/, ''))}`;
const notePath = String(full.filePath || full.originalPath || '').replace(/\\/g, '/');
return `/api/attachments/resolve?name=${encodeURIComponent(p)}&note=${encodeURIComponent(notePath)}`;
}
} catch {}
return null;
}
getThumbnailSrcById(meta: NoteMetadata): string | null {
try {
const full = this.getFullNoteById(meta.id) as any;
const path = full?.filePath || meta.filePath || '';
const kind = this.fileTypes.getViewerType(path, full?.rawContent ?? full?.content ?? '');
if (kind === 'image') {
return `/vault/${encodeURI(path)}`;
}
return this.extractFirstImageFromFull(full);
} catch { return null; }
}
// Sort/View menus // Sort/View menus
toggleSortMenu(): void { this.sortMenuOpen.set(!this.sortMenuOpen()); this.viewModeMenuOpen.set(false); } toggleSortMenu(): void { this.sortMenuOpen.set(!this.sortMenuOpen()); this.viewModeMenuOpen.set(false); }
toggleViewModeMenu(): void { this.viewModeMenuOpen.set(!this.viewModeMenuOpen()); this.sortMenuOpen.set(false); } toggleViewModeMenu(): void { this.viewModeMenuOpen.set(!this.viewModeMenuOpen()); this.sortMenuOpen.set(false); }

View File

@ -1,4 +1,4 @@
import { Component, EventEmitter, HostListener, Input, Output, inject, effect, AfterViewInit, ViewChild, ElementRef } from '@angular/core'; import { Component, EventEmitter, HostListener, Input, Output, inject, effect, signal, computed, AfterViewInit, ViewChild, ElementRef } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { UiModeService } from '../../shared/services/ui-mode.service'; import { UiModeService } from '../../shared/services/ui-mode.service';
import { ResponsiveService } from '../../shared/services/responsive.service'; import { ResponsiveService } from '../../shared/services/responsive.service';
@ -431,6 +431,22 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
quickLinkFilter: 'favoris' | 'publish' | 'draft' | 'template' | 'task' | 'private' | 'archive' | 'bookmarks' | null = null; quickLinkFilter: 'favoris' | 'publish' | 'draft' | 'template' | 'task' | 'private' | 'archive' | 'bookmarks' | null = null;
private suppressNextNoteSelection = false; private suppressNextNoteSelection = false;
// Signaux pour tracker les filtres et déclencher la sélection automatique
private filterStateSignal = signal<{
tag: string | null;
folder: string | null;
quick: string | null;
search: string;
}>({
tag: null,
folder: null,
quick: null,
search: ''
});
// Signal pour forcer un recalcul de la liste filtrée
private filterChangeCounter = signal(0);
// --- URL State <-> Layout sync --- // --- URL State <-> Layout sync ---
private mapUrlQuickToInternal(q: string | null): AppShellNimbusLayoutComponent['quickLinkFilter'] { private mapUrlQuickToInternal(q: string | null): AppShellNimbusLayoutComponent['quickLinkFilter'] {
switch ((q || '').toLowerCase()) { switch ((q || '').toLowerCase()) {
@ -525,7 +541,7 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
this.tagFilter = norm || null; this.tagFilter = norm || null;
this.folderFilter = null; this.folderFilter = null;
this.quickLinkFilter = null; this.quickLinkFilter = null;
if (!hasNote) this.autoSelectFirstNote(); if (!hasNote) this.notifyFilterChange();
if (!this.responsive.isDesktop()) this.mobileNav.setActiveTab('list'); if (!this.responsive.isDesktop()) this.mobileNav.setActiveTab('list');
} }
// Auto-open tags flyout when tag filter is active // Auto-open tags flyout when tag filter is active
@ -538,7 +554,7 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
this.folderFilter = folder || null; this.folderFilter = folder || null;
this.tagFilter = null; this.tagFilter = null;
this.quickLinkFilter = null; this.quickLinkFilter = null;
if (!hasNote) this.autoSelectFirstNote(); if (!hasNote) this.notifyFilterChange();
if (!this.responsive.isDesktop()) this.mobileNav.setActiveTab('list'); if (!this.responsive.isDesktop()) this.mobileNav.setActiveTab('list');
} }
// Auto-open folders flyout when folder filter is active // Auto-open folders flyout when folder filter is active
@ -552,12 +568,12 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
this.quickLinkFilter = internal; this.quickLinkFilter = internal;
this.folderFilter = null; this.folderFilter = null;
this.tagFilter = null; this.tagFilter = null;
this.autoSelectFirstNote(); this.notifyFilterChange();
if (!this.responsive.isDesktop()) { if (!this.responsive.isDesktop()) {
this.mobileNav.setActiveTab('list'); this.mobileNav.setActiveTab('list');
} }
} else { } else {
this.autoSelectFirstNote(); this.notifyFilterChange();
} }
// Auto-open quick flyout when quick filter is active // Auto-open quick flyout when quick filter is active
if (this.hoveredFlyout !== 'quick') { if (this.hoveredFlyout !== 'quick') {
@ -571,7 +587,7 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
this.folderFilter = null; this.folderFilter = null;
this.tagFilter = null; this.tagFilter = null;
this.quickLinkFilter = null; this.quickLinkFilter = null;
this.autoSelectFirstNote(); this.notifyFilterChange();
} }
this.suppressNextNoteSelection = false; this.suppressNextNoteSelection = false;
// Close any open flyout when no filters // Close any open flyout when no filters
@ -589,11 +605,58 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
}); });
}); });
// Effect pour sélection automatique quand les filtres changent
private _autoSelectEffect = effect(() => {
// Écouter le signal de changement de filtre
const filterState = this.filterStateSignal();
const counter = this.filterChangeCounter();
// Éviter l'exécution lors de l'initialisation
if (counter === 0) return;
console.log('🎯 Auto-select effect triggered:', { filterState, counter });
// Utiliser queueMicrotask pour s'assurer que la liste est mise à jour
queueMicrotask(() => {
this.autoSelectFirstNote(true);
});
});
// Méthode helper pour notifier les changements de filtres
private notifyFilterChange() {
const currentState = {
tag: this.tagFilter,
folder: this.folderFilter,
quick: this.quickLinkFilter,
search: this.listQuery
};
console.log('🔔 Notifying filter change:', currentState);
// Mettre à jour le signal d'état
this.filterStateSignal.set(currentState);
// Incrémenter le compteur pour déclencher l'effect
this.filterChangeCounter.update(c => c + 1);
}
// Auto-select first note when filters change // Auto-select first note when filters change
private autoSelectFirstNote() { private autoSelectFirstNote(forceSelection = false) {
const filteredNotes = this.getFilteredNotes(); const filteredNotes = this.getFilteredNotes();
if (filteredNotes.length > 0 && filteredNotes[0].id !== this.selectedNoteId) { console.log('🎯 autoSelectFirstNote called:', {
this.noteSelected.emit(filteredNotes[0].id); forceSelection,
notesCount: filteredNotes.length,
firstNoteId: filteredNotes[0]?.id,
selectedNoteId: this.selectedNoteId
});
if (filteredNotes.length > 0) {
const firstNote = filteredNotes[0];
// Forcer la sélection si demandé, ou si la note est différente
if (forceSelection || firstNote.id !== this.selectedNoteId) {
console.log('✅ Emitting noteSelected for:', firstNote.title, firstNote.id);
this.noteSelected.emit(firstNote.id);
}
} }
} }
@ -685,8 +748,8 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
onQueryChange(query: string) { onQueryChange(query: string) {
this.listQuery = query; this.listQuery = query;
// Auto-select first note on any list change including search updates // Notifier le changement pour déclencher la sélection automatique
this.autoSelectFirstNote(); this.notifyFilterChange();
// Sync URL search term // Sync URL search term
this.urlState.updateSearch(query); this.urlState.updateSearch(query);
} }
@ -747,6 +810,7 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
onClearFolderFromList() { onClearFolderFromList() {
this.folderFilter = null; this.folderFilter = null;
this.notifyFilterChange();
this.urlState.clearFolderFilter(); this.urlState.clearFolderFilter();
} }
@ -807,7 +871,8 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
this.listQuery = ''; this.listQuery = '';
try { this.filters.clearKinds(); } catch {} try { this.filters.clearKinds(); } catch {}
try { this.filters.clearTags(); } catch {} try { this.filters.clearTags(); } catch {}
this.autoSelectFirstNote(); // Notifier le changement pour déclencher la sélection automatique
this.notifyFilterChange();
if (this.responsive.isMobile() || this.responsive.isTablet()) { if (this.responsive.isMobile() || this.responsive.isTablet()) {
this.mobileNav.setActiveTab('list'); this.mobileNav.setActiveTab('list');
} }
@ -829,7 +894,8 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
this.listQuery = ''; this.listQuery = '';
try { this.filters.clearKinds(); } catch {} try { this.filters.clearKinds(); } catch {}
try { this.filters.clearTags(); } catch {} try { this.filters.clearTags(); } catch {}
this.autoSelectFirstNote(); // Notifier le changement pour déclencher la sélection automatique
this.notifyFilterChange();
this.mobileNav.setActiveTab('list'); this.mobileNav.setActiveTab('list');
this.mobileNav.sidebarOpen.set(false); this.mobileNav.sidebarOpen.set(false);
if (path) { if (path) {
@ -987,8 +1053,8 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
this.urlState.setQuickWithMarkdown(label); this.urlState.setQuickWithMarkdown(label);
} }
} }
// Auto-select first note after filter changes // Notifier le changement pour déclencher la sélection automatique
this.autoSelectFirstNote(); this.notifyFilterChange();
this.suppressNextNoteSelection = false; this.suppressNextNoteSelection = false;
} }
@ -1000,8 +1066,8 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
// Clear other filters and search to focus on tag results // Clear other filters and search to focus on tag results
this.quickLinkFilter = null; this.quickLinkFilter = null;
this.listQuery = ''; this.listQuery = '';
// Auto-select first note after filter changes // Notifier le changement pour déclencher la sélection automatique
this.autoSelectFirstNote(); this.notifyFilterChange();
// Ensure the list is visible: exit fullscreen if active // Ensure the list is visible: exit fullscreen if active
if (this.noteFullScreen) { if (this.noteFullScreen) {
this.noteFullScreen = false; this.noteFullScreen = false;
@ -1104,6 +1170,6 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
this.tagFilter = null; this.tagFilter = null;
this.quickLinkFilter = null; this.quickLinkFilter = null;
this.listQuery = ''; this.listQuery = '';
this.autoSelectFirstNote(); this.notifyFilterChange();
} }
} }

View File

@ -1,13 +1,10 @@
--- ---
titre: test titre: "test"
auteur: Bruno Charest auteur: "Bruno Charest"
creation_date: 2025-09-25T07:45:20-04:00 creation_date: "2025-09-25T07:45:20-04:00"
modification_date: 2025-11-02T12:07:38-04:00 modification_date: "2025-11-02T12:07:38-04:00"
catégorie: "" tags: [""]
tags: [] status: "en-cours"
aliases:
- ""
status: en-cours
publish: true publish: true
favoris: false favoris: false
template: true template: true
@ -15,14 +12,14 @@ task: true
archive: true archive: true
draft: true draft: true
private: true private: true
first_name: Bruno first_name: "Bruno"
birth_date: 2025-06-18 birth_date: "2025-06-18"
email: bruno.charest@gmail.com email: "bruno.charest@gmail.com"
number: "12345" 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"
color: "#64748B" color: "#22C55E"
--- ---
# Test 1 Markdown # Test 1 Markdown

View File

@ -9,7 +9,7 @@ publish: false
favoris: true favoris: true
template: false template: false
task: false task: false
archive: false archive: true
draft: false draft: false
private: false private: false
description: "Stargate Atlantis: une expédition militaire et scientifique découvre la cité mythique d'Atlantis dans la galaxie de Pégase et affronte les Wraiths." description: "Stargate Atlantis: une expédition militaire et scientifique découvre la cité mythique d'Atlantis dans la galaxie de Pégase et affronte les Wraiths."

View File

@ -12,24 +12,9 @@ archive: false
draft: true draft: true
private: false private: false
titre: "" titre: ""
Les Compléments Alimentaires: "Un Guide Général"
catégorie: ""
readOnly: false readOnly: false
description: "Les Compléments Alimentaires : Un Guide Général Dans notre quête constante de bien-être et de..." description: "Les Compléments Alimentaires : Un Guide Général Dans notre quête constante de bien-être et de..."
tags: color: "#00AEEF"
- supplments
- tag2
- configuration
- accueil
- home
- markdown
- bruno
- tag4
- tag3
- test
- tag1
- test2
- tagtag
--- ---
## Les Compléments Alimentaires : Un Guide Général ## Les Compléments Alimentaires : Un Guide Général