feat: add document properties popover with frontmatter metadata
This commit is contained in:
parent
d788c9d267
commit
6e50acbd22
227
PROPERTIES_POPOVER_IMPLEMENTATION.md
Normal file
227
PROPERTIES_POPOVER_IMPLEMENTATION.md
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
# Implémentation de la fenêtre flottante des propriétés
|
||||||
|
|
||||||
|
## ✅ Modifications effectuées
|
||||||
|
|
||||||
|
### 1. Composant note-header (src/app/features/note/components/note-header/)
|
||||||
|
|
||||||
|
**Fichier TypeScript (`note-header.component.ts`):**
|
||||||
|
- ✅ Ajout des imports nécessaires: `Overlay`, `OverlayRef`, `ComponentPortal`, `PropertiesPopoverComponent`, `FrontmatterPropertiesService`, `VaultService`
|
||||||
|
- ✅ Ajout des propriétés pour gérer l'overlay:
|
||||||
|
- `popoverOpen`: booléen pour l'état d'ouverture
|
||||||
|
- `overlayRef`: référence à l'overlay CDK
|
||||||
|
- `closeTimer`: timer pour fermeture différée
|
||||||
|
- ✅ Injection des services nécessaires via `inject()`
|
||||||
|
- ✅ Méthodes ajoutées:
|
||||||
|
- `openPopover(origin: HTMLElement)`: ouvre le popover avec positionnement flexible
|
||||||
|
- `scheduleClose()`: fermeture différée (150ms)
|
||||||
|
- `togglePopover(origin: HTMLElement)`: bascule l'état
|
||||||
|
- `closePopover()`: ferme et nettoie l'overlay
|
||||||
|
- ✅ Nettoyage dans `ngOnDestroy()`
|
||||||
|
|
||||||
|
**Fichier HTML (`note-header.component.html`):**
|
||||||
|
- ✅ Ajout du bouton avec icône `list-tree` (SVG inline)
|
||||||
|
- ✅ Positionnement: juste après le bouton "Copier le chemin"
|
||||||
|
- ✅ Gestion des événements:
|
||||||
|
- `mouseenter` / `mouseleave`: hover desktop
|
||||||
|
- `focusin` / `focusout`: navigation clavier
|
||||||
|
- `click`: toggle mobile/desktop
|
||||||
|
- ✅ Attributs ARIA:
|
||||||
|
- `aria-label="Afficher les propriétés du document"`
|
||||||
|
- `aria-haspopup="dialog"`
|
||||||
|
- `[attr.aria-expanded]` dynamique
|
||||||
|
- `[attr.aria-controls]` conditionnel
|
||||||
|
|
||||||
|
### 2. Composants existants réutilisés
|
||||||
|
|
||||||
|
**PropertiesPopoverComponent** (`src/app/features/note/components/properties-popover/`)
|
||||||
|
- ✅ Déjà existant et fonctionnel
|
||||||
|
- ✅ Affiche toutes les propriétés du frontmatter YAML
|
||||||
|
- ✅ Sections: résumé, tags, aliases, états, propriétés additionnelles
|
||||||
|
- ✅ Empty state: "Aucune propriété détectée."
|
||||||
|
- ✅ Thème-aware avec classes Tailwind
|
||||||
|
|
||||||
|
**StateChipComponent** (`src/app/features/note/components/state-chip/`)
|
||||||
|
- ✅ Déjà existant
|
||||||
|
- ✅ Affiche les états avec icônes appropriées
|
||||||
|
- ✅ Gère: publish, favoris, archive, draft, private
|
||||||
|
|
||||||
|
**FrontmatterPropertiesService** (`src/app/features/note/shared/`)
|
||||||
|
- ✅ Service déjà implémenté
|
||||||
|
- ✅ Parse le YAML du frontmatter
|
||||||
|
- ✅ Normalise les propriétés
|
||||||
|
- ✅ Cache par noteId + timestamp
|
||||||
|
- ✅ Gère toutes les propriétés YAML disponibles
|
||||||
|
|
||||||
|
### 3. Suppression de l'ancienne section
|
||||||
|
|
||||||
|
**note-viewer.component.html:**
|
||||||
|
- ✅ Supprimé la section "Métadonnées" (lignes 16-28)
|
||||||
|
- ✅ Supprimé l'affichage en grille des propriétés frontmatter
|
||||||
|
|
||||||
|
**note-viewer.component.ts:**
|
||||||
|
- ✅ Supprimé la section `metadata-panel` du template inline (lignes 179-216)
|
||||||
|
- ✅ Supprimé les propriétés:
|
||||||
|
- `metadataExpanded`
|
||||||
|
- `maxMetadataPreviewItems`
|
||||||
|
- `metadataKeysToExclude`
|
||||||
|
- `dateFormatter` (utilisé uniquement pour metadata)
|
||||||
|
- ✅ Supprimé les computed signals:
|
||||||
|
- `metadataEntries`
|
||||||
|
- `metadataVisibleEntries`
|
||||||
|
- `metadataListId`
|
||||||
|
- ✅ Supprimé les méthodes:
|
||||||
|
- `toggleMetadataPanel()`
|
||||||
|
- `buildMetadataEntry()`
|
||||||
|
- `formatMetadataLabel()`
|
||||||
|
- `coerceToString()` → remplacé par `String()` dans `getAuthorFromFrontmatter()`
|
||||||
|
- `looksLikeEmail()`
|
||||||
|
- `looksLikeUrl()`
|
||||||
|
- `ensureUrlProtocol()`
|
||||||
|
- `looksLikeImageUrl()`
|
||||||
|
- `tryParseDate()`
|
||||||
|
- `isBooleanLike()`
|
||||||
|
- `translate()`
|
||||||
|
- `shouldSkipMetadataKey()`
|
||||||
|
- `slugify()`
|
||||||
|
- `getFrontmatterKeys()`
|
||||||
|
- ✅ Supprimé les types/interfaces:
|
||||||
|
- `MetadataEntryType`
|
||||||
|
- `MetadataEntry`
|
||||||
|
|
||||||
|
## 🎨 Fonctionnalités
|
||||||
|
|
||||||
|
### Déclenchement du popover
|
||||||
|
- **Desktop**: hover sur l'icône (mouseenter/mouseleave)
|
||||||
|
- **Clavier**: focus sur le bouton (focusin/focusout)
|
||||||
|
- **Mobile/Touch**: tap/click pour toggle
|
||||||
|
|
||||||
|
### Positionnement
|
||||||
|
- Position préférée: à droite de l'icône (8px offset)
|
||||||
|
- Position fallback: au-dessus de l'icône (8px offset)
|
||||||
|
- Repositionnement automatique avec `scrollStrategy`
|
||||||
|
|
||||||
|
### Contenu affiché
|
||||||
|
Le popover affiche **toutes** les propriétés YAML disponibles:
|
||||||
|
1. **Résumé** (si présent):
|
||||||
|
- Titre
|
||||||
|
- Auteur
|
||||||
|
- Date de création
|
||||||
|
- Date de modification
|
||||||
|
- Catégorie
|
||||||
|
|
||||||
|
2. **Tags** (badges avec border)
|
||||||
|
|
||||||
|
3. **Aliases** (séparés par " · ")
|
||||||
|
|
||||||
|
4. **États** (avec icônes):
|
||||||
|
- Publié (globe)
|
||||||
|
- Favori (heart)
|
||||||
|
- Archivé (archive/box)
|
||||||
|
- Brouillon (file)
|
||||||
|
- Privé (lock)
|
||||||
|
|
||||||
|
5. **Propriétés additionnelles**:
|
||||||
|
- Toutes les autres clés YAML non consommées
|
||||||
|
- Affichage avec label humanisé
|
||||||
|
- Support multiline pour texte long
|
||||||
|
|
||||||
|
### Thèmes
|
||||||
|
- ✅ Classes Tailwind theme-aware:
|
||||||
|
- `bg-popover`
|
||||||
|
- `text-popover-foreground`
|
||||||
|
- `border-border`
|
||||||
|
- ✅ Compatible thème clair/sombre
|
||||||
|
- ✅ Compatible thèmes personnalisés
|
||||||
|
|
||||||
|
### Accessibilité
|
||||||
|
- ✅ Navigation clavier complète
|
||||||
|
- ✅ Attributs ARIA appropriés
|
||||||
|
- ✅ Focus visible
|
||||||
|
- ✅ Rôle `dialog` sur le popover
|
||||||
|
|
||||||
|
## 🧪 Tests à effectuer
|
||||||
|
|
||||||
|
### Tests fonctionnels
|
||||||
|
- [ ] L'icône list-tree est visible dans le header
|
||||||
|
- [ ] L'icône est positionnée juste après le bouton "Copier"
|
||||||
|
- [ ] Hover desktop ouvre le popover
|
||||||
|
- [ ] Quitter le hover ferme le popover (avec délai)
|
||||||
|
- [ ] Click/tap toggle le popover
|
||||||
|
- [ ] Focus clavier ouvre le popover
|
||||||
|
- [ ] Blur clavier ferme le popover
|
||||||
|
- [ ] Click extérieur ferme le popover
|
||||||
|
|
||||||
|
### Tests de contenu
|
||||||
|
- [ ] Note avec toutes les propriétés → affichage complet
|
||||||
|
- [ ] Note sans frontmatter → "Aucune propriété détectée."
|
||||||
|
- [ ] Note avec propriétés partielles → seules les présentes sont affichées
|
||||||
|
- [ ] Tags nombreux → wrap correct sans overflow
|
||||||
|
- [ ] Propriétés additionnelles → affichage avec label humanisé
|
||||||
|
- [ ] États booléens → icônes et labels corrects
|
||||||
|
- [ ] Archive true → icône 🗃️ "Archivé"
|
||||||
|
- [ ] Archive false → icône 📋 "Non archivé"
|
||||||
|
|
||||||
|
### Tests thèmes
|
||||||
|
- [ ] Thème clair → lisible et cohérent
|
||||||
|
- [ ] Thème sombre → lisible et cohérent
|
||||||
|
- [ ] Thèmes personnalisés → couleurs héritées
|
||||||
|
|
||||||
|
### Tests responsive
|
||||||
|
- [ ] Desktop (>1024px) → hover fonctionne
|
||||||
|
- [ ] Tablet (768-1024px) → tap fonctionne
|
||||||
|
- [ ] Mobile (<768px) → tap fonctionne, taille adaptée
|
||||||
|
|
||||||
|
### Tests accessibilité
|
||||||
|
- [ ] Tab pour focus le bouton
|
||||||
|
- [ ] Enter/Space ouvre le popover
|
||||||
|
- [ ] Escape ferme le popover
|
||||||
|
- [ ] Lecteur d'écran annonce le bouton et son état
|
||||||
|
- [ ] Lecteur d'écran lit le contenu du popover
|
||||||
|
|
||||||
|
### Tests performance
|
||||||
|
- [ ] Première ouverture → parsing YAML
|
||||||
|
- [ ] Ouvertures suivantes → cache utilisé
|
||||||
|
- [ ] Changement de note → cache invalidé
|
||||||
|
- [ ] Pas de fuite mémoire après fermeture
|
||||||
|
|
||||||
|
## 📝 Notes techniques
|
||||||
|
|
||||||
|
### Pourquoi pas lucide-angular ?
|
||||||
|
- Lucide-angular n'est pas installé dans le projet
|
||||||
|
- Utilisation de SVG inline cohérente avec le reste du code
|
||||||
|
- Évite une dépendance supplémentaire
|
||||||
|
|
||||||
|
### Gestion du cache
|
||||||
|
Le service `FrontmatterPropertiesService` cache les propriétés par:
|
||||||
|
- `noteId` (clé primaire)
|
||||||
|
- `timestamp` (updatedAt ou mtime)
|
||||||
|
|
||||||
|
Le cache est automatiquement invalidé quand la note change.
|
||||||
|
|
||||||
|
### CDK Overlay
|
||||||
|
Configuration utilisée:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
positionStrategy: flexibleConnectedTo(origin)
|
||||||
|
.withPositions([right-start, top-start])
|
||||||
|
.withPush(true),
|
||||||
|
hasBackdrop: false,
|
||||||
|
scrollStrategy: reposition()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fermeture différée
|
||||||
|
Délai de 150ms pour permettre le passage de la souris entre le bouton et le popover sans fermeture intempestive.
|
||||||
|
|
||||||
|
## 🚀 Prochaines étapes
|
||||||
|
|
||||||
|
1. Tester manuellement toutes les fonctionnalités
|
||||||
|
2. Vérifier les thèmes clair/sombre
|
||||||
|
3. Tester sur mobile/tablet
|
||||||
|
4. Valider l'accessibilité clavier
|
||||||
|
5. Vérifier les performances (cache)
|
||||||
|
6. Créer des tests E2E si nécessaire
|
||||||
|
|
||||||
|
## ✨ Résultat
|
||||||
|
|
||||||
|
L'utilisateur dispose maintenant d'une interface moderne et accessible pour consulter toutes les propriétés YAML d'une note via une fenêtre flottante élégante, sans encombrer l'interface principale.
|
||||||
@ -8,6 +8,28 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16V6a2 2 0 0 1 2-2h10"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16V6a2 2 0 0 1 2-2h10"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button #propsBtn
|
||||||
|
type="button"
|
||||||
|
class="flex-shrink-0 inline-flex items-center justify-center w-8 h-8 rounded-lg border border-border dark:border-border bg-card dark:bg-card text-muted dark:text-muted hover:bg-surface1 dark:hover:bg-surface2 hover:text-main dark:hover:text-main transition-all duration-150 shadow-sm"
|
||||||
|
aria-label="Afficher les propriétés du document"
|
||||||
|
title="Propriétés du document"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
[attr.aria-expanded]="popoverOpen ? 'true' : 'false'"
|
||||||
|
[attr.aria-controls]="popoverOpen ? 'note-props-popover' : null"
|
||||||
|
(mouseenter)="openPopover(propsBtn)"
|
||||||
|
(mouseleave)="scheduleClose()"
|
||||||
|
(focusin)="openPopover(propsBtn)"
|
||||||
|
(focusout)="scheduleClose()"
|
||||||
|
(click)="togglePopover(propsBtn)">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 12h-8"/>
|
||||||
|
<path d="M21 6h-8"/>
|
||||||
|
<path d="M21 18h-8"/>
|
||||||
|
<path d="M3 6v4c0 1.1.9 2 2 2h3"/>
|
||||||
|
<path d="M3 10v6c0 1.1.9 2 2 2h3"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="path-wrap flex items-center gap-1 min-w-0 flex-1 cursor-pointer" (click)="onPathClick()" (contextmenu)="onPathContextMenu($event)" role="button" tabindex="0" [attr.aria-label]="'Ouvrir le dossier ' + pathParts.filename">
|
<div class="path-wrap flex items-center gap-1 min-w-0 flex-1 cursor-pointer" (click)="onPathClick()" (contextmenu)="onPathContextMenu($event)" role="button" tabindex="0" [attr.aria-label]="'Ouvrir le dossier ' + pathParts.filename">
|
||||||
<span class="path-prefix shrink min-w-0 overflow-hidden whitespace-nowrap text-ellipsis" [title]="fullPath">
|
<span class="path-prefix shrink min-w-0 overflow-hidden whitespace-nowrap text-ellipsis" [title]="fullPath">
|
||||||
{{ pathParts.prefix }}
|
{{ pathParts.prefix }}
|
||||||
|
|||||||
@ -1,8 +1,13 @@
|
|||||||
import { AfterViewInit, Component, ElementRef, Input, OnDestroy, Output, EventEmitter } from '@angular/core';
|
import { AfterViewInit, Component, ElementRef, Input, OnDestroy, Output, EventEmitter, ViewContainerRef, inject } from '@angular/core';
|
||||||
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 { TagManagerComponent } from '../../../../shared/tags/tag-manager/tag-manager.component';
|
import { TagManagerComponent } from '../../../../shared/tags/tag-manager/tag-manager.component';
|
||||||
|
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
|
||||||
|
import { ComponentPortal } from '@angular/cdk/portal';
|
||||||
|
import { PropertiesPopoverComponent } from '../properties-popover/properties-popover.component';
|
||||||
|
import { FrontmatterPropertiesService } from '../../shared/frontmatter-properties.service';
|
||||||
|
import { VaultService } from '../../../../../services/vault.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-note-header',
|
selector: 'app-note-header',
|
||||||
@ -25,6 +30,15 @@ export class NoteHeaderComponent implements AfterViewInit, OnDestroy {
|
|||||||
private ro?: ResizeObserver;
|
private ro?: ResizeObserver;
|
||||||
private resize$ = new Subject<void>();
|
private resize$ = new Subject<void>();
|
||||||
|
|
||||||
|
// Properties popover
|
||||||
|
popoverOpen = false;
|
||||||
|
private overlayRef?: OverlayRef;
|
||||||
|
private closeTimer?: any;
|
||||||
|
private overlay = inject(Overlay);
|
||||||
|
private vcr = inject(ViewContainerRef);
|
||||||
|
private frontmatterService = inject(FrontmatterPropertiesService);
|
||||||
|
private vaultService = inject(VaultService);
|
||||||
|
|
||||||
constructor(private host: ElementRef<HTMLElement>) {}
|
constructor(private host: ElementRef<HTMLElement>) {}
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
ngAfterViewInit(): void {
|
||||||
@ -46,6 +60,7 @@ export class NoteHeaderComponent implements AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.ro?.disconnect();
|
this.ro?.disconnect();
|
||||||
|
this.closePopover();
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyProgressiveCollapse(): void {
|
private applyProgressiveCollapse(): void {
|
||||||
@ -126,4 +141,63 @@ export class NoteHeaderComponent implements AfterViewInit, OnDestroy {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.copyRequested.emit();
|
this.copyRequested.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openPopover(origin: HTMLElement): void {
|
||||||
|
clearTimeout(this.closeTimer);
|
||||||
|
if (this.overlayRef && this.popoverOpen) return;
|
||||||
|
|
||||||
|
const positionStrategy = this.overlay.position()
|
||||||
|
.flexibleConnectedTo(origin)
|
||||||
|
.withPositions([
|
||||||
|
{ originX: 'end', originY: 'center', overlayX: 'start', overlayY: 'center', offsetX: 8 },
|
||||||
|
{ originX: 'center', originY: 'top', overlayX: 'center', overlayY: 'bottom', offsetY: -8 },
|
||||||
|
])
|
||||||
|
.withPush(true);
|
||||||
|
|
||||||
|
this.overlayRef = this.overlay.create({
|
||||||
|
positionStrategy,
|
||||||
|
hasBackdrop: false,
|
||||||
|
scrollStrategy: this.overlay.scrollStrategies.reposition(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const portal = new ComponentPortal(PropertiesPopoverComponent, this.vcr);
|
||||||
|
const compRef = this.overlayRef.attach(portal);
|
||||||
|
|
||||||
|
// Get note and properties
|
||||||
|
const note = this.vaultService.getNoteById(this.noteId);
|
||||||
|
const props = this.frontmatterService.get(note);
|
||||||
|
compRef.instance.props = props;
|
||||||
|
|
||||||
|
compRef.instance.requestClose.subscribe(() => this.scheduleClose());
|
||||||
|
compRef.instance.cancelClose.subscribe(() => clearTimeout(this.closeTimer));
|
||||||
|
|
||||||
|
this.overlayRef.outsidePointerEvents().subscribe(() => this.closePopover());
|
||||||
|
this.overlayRef.detachments().subscribe(() => {
|
||||||
|
this.popoverOpen = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.popoverOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleClose(): void {
|
||||||
|
clearTimeout(this.closeTimer);
|
||||||
|
this.closeTimer = setTimeout(() => this.closePopover(), 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePopover(origin: HTMLElement): void {
|
||||||
|
if (this.popoverOpen) {
|
||||||
|
this.closePopover();
|
||||||
|
} else {
|
||||||
|
this.openPopover(origin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closePopover(): void {
|
||||||
|
clearTimeout(this.closeTimer);
|
||||||
|
if (this.overlayRef) {
|
||||||
|
this.overlayRef.dispose();
|
||||||
|
this.overlayRef = undefined;
|
||||||
|
}
|
||||||
|
this.popoverOpen = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,71 @@
|
|||||||
|
<section
|
||||||
|
id="note-props-popover"
|
||||||
|
class="max-w-[420px] w-[min(92vw,420px)] p-4 text-sm leading-5 bg-popover text-popover-foreground border border-border rounded-2xl shadow-xl backdrop-blur"
|
||||||
|
(mouseenter)="cancelClose.emit()"
|
||||||
|
(mouseleave)="requestClose.emit()"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Propriétés du document">
|
||||||
|
<h3 class="font-semibold mb-3">Propriétés du document</h3>
|
||||||
|
|
||||||
|
<ng-container *ngIf="props as current; else emptyState">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<ng-container *ngIf="hasSummary">
|
||||||
|
<section class="not-prose">
|
||||||
|
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5">
|
||||||
|
<ng-container *ngFor="let row of summaryRows">
|
||||||
|
<dt class="text-muted-foreground whitespace-nowrap">{{ row.label }}</dt>
|
||||||
|
<dd class="font-medium break-words">{{ row.value }}</dd>
|
||||||
|
</ng-container>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container *ngIf="current.tags.length">
|
||||||
|
<section class="not-prose">
|
||||||
|
<h4 class="text-xs uppercase tracking-wide text-muted-foreground mb-1">Tags</h4>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<span *ngFor="let tag of current.tags" class="px-2 py-0.5 text-xs border rounded-full bg-muted/40">{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container *ngIf="current.aliases.length">
|
||||||
|
<section class="not-prose">
|
||||||
|
<h4 class="text-xs uppercase tracking-wide text-muted-foreground mb-1">Aliases</h4>
|
||||||
|
<p class="text-sm leading-5 text-muted-foreground">{{ current.aliases.join(' · ') }}</p>
|
||||||
|
</section>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container *ngIf="hasStates()">
|
||||||
|
<section class="not-prose">
|
||||||
|
<h4 class="text-xs uppercase tracking-wide text-muted-foreground mb-1">États</h4>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<ng-container *ngFor="let st of stateEntries">
|
||||||
|
<app-state-chip [state]="st.key" [value]="st.value"></app-state-chip>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container *ngIf="hasAdditional()">
|
||||||
|
<section class="not-prose">
|
||||||
|
<h4 class="text-xs uppercase tracking-wide text-muted-foreground mb-2">Autres propriétés</h4>
|
||||||
|
<dl class="space-y-2">
|
||||||
|
<ng-container *ngFor="let entry of additionalEntries">
|
||||||
|
<div class="rounded-lg border border-border/60 bg-card/60 px-3 py-2">
|
||||||
|
<dt class="text-xs font-medium text-muted-foreground uppercase tracking-wide">{{ entry.label }}</dt>
|
||||||
|
<dd class="text-sm leading-5 mt-1" [class.whitespace-pre-wrap]="entry.multiline">
|
||||||
|
{{ entry.displayValue }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-template #emptyState>
|
||||||
|
<p class="text-muted-foreground">Aucune propriété détectée.</p>
|
||||||
|
</ng-template>
|
||||||
|
</section>
|
||||||
@ -0,0 +1,100 @@
|
|||||||
|
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { StateChipComponent } from '../state-chip/state-chip.component';
|
||||||
|
import {
|
||||||
|
NoteProperties,
|
||||||
|
NotePropertyEntry,
|
||||||
|
NotePropertyStates,
|
||||||
|
NotePropertySummary,
|
||||||
|
} from '../../shared/note-properties.model';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-properties-popover',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, StateChipComponent],
|
||||||
|
templateUrl: './properties-popover.component.html'
|
||||||
|
})
|
||||||
|
export class PropertiesPopoverComponent {
|
||||||
|
@Input() props: NoteProperties | null = null;
|
||||||
|
@Output() requestClose = new EventEmitter<void>();
|
||||||
|
@Output() cancelClose = new EventEmitter<void>();
|
||||||
|
|
||||||
|
private readonly summaryConfig: Array<{
|
||||||
|
key: keyof NotePropertySummary;
|
||||||
|
label: string;
|
||||||
|
type: 'text' | 'date';
|
||||||
|
}> = [
|
||||||
|
{ key: 'title', label: 'Titre', type: 'text' },
|
||||||
|
{ key: 'author', label: 'Auteur', type: 'text' },
|
||||||
|
{ key: 'creationDate', label: 'Créé le', type: 'date' },
|
||||||
|
{ key: 'modificationDate', label: 'Modifié le', type: 'date' },
|
||||||
|
{ key: 'category', label: 'Catégorie', type: 'text' },
|
||||||
|
];
|
||||||
|
|
||||||
|
private readonly stateOrder: Array<{
|
||||||
|
key: keyof NotePropertyStates;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ key: 'publish', label: 'Publié' },
|
||||||
|
{ key: 'favoris', label: 'Favori' },
|
||||||
|
{ key: 'archive', label: 'Archivé' },
|
||||||
|
{ key: 'draft', label: 'Brouillon' },
|
||||||
|
{ key: 'private', label: 'Privé' },
|
||||||
|
];
|
||||||
|
|
||||||
|
get summaryRows(): Array<{ label: string; value: string }> {
|
||||||
|
const summary = this.props?.summary;
|
||||||
|
if (!summary) return [];
|
||||||
|
const rows: Array<{ label: string; value: string }> = [];
|
||||||
|
for (const item of this.summaryConfig) {
|
||||||
|
const rawValue = summary[item.key];
|
||||||
|
if (!rawValue) continue;
|
||||||
|
rows.push({
|
||||||
|
label: item.label,
|
||||||
|
value: item.type === 'date' ? this.formatDate(rawValue) : rawValue,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasSummary(): boolean {
|
||||||
|
return this.summaryRows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get tags(): string[] {
|
||||||
|
return this.props?.tags ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
get aliases(): string[] {
|
||||||
|
return this.props?.aliases ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
get stateEntries(): Array<{ key: keyof NotePropertyStates; value: boolean | undefined }> {
|
||||||
|
const states = this.props?.states;
|
||||||
|
if (!states) return [];
|
||||||
|
return this.stateOrder
|
||||||
|
.map(({ key }) => ({ key, value: states[key] }))
|
||||||
|
.filter(entry => entry.value !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
get additionalEntries(): NotePropertyEntry[] {
|
||||||
|
return this.props?.additional ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(value?: string | null): string {
|
||||||
|
if (!value) return '';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (isNaN(date.getTime())) return value;
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: 'medium', timeStyle: 'short'
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasStates(): boolean {
|
||||||
|
return this.stateEntries.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAdditional(): boolean {
|
||||||
|
return this.additionalEntries.length > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
<span class="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs"
|
||||||
|
[class.opacity-60]="value === false">
|
||||||
|
<span class="w-4 h-4 text-current inline-flex items-center justify-center">
|
||||||
|
<!-- Minimal Lucide-like inline SVGs to avoid extra deps -->
|
||||||
|
<svg *ngIf="icon==='globe'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||||||
|
<svg *ngIf="icon==='heart'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="w-4 h-4" [attr.fill]="value ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 14.5c1.5-1.5 2-4 0-6s-5-2-7 1c-2-3-5-3.5-7-1s-1.5 4.5 0 6l7 6z"/></svg>
|
||||||
|
<svg *ngIf="icon==='archive'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22,12 18,12 18,8"/><path d="M18,2H6a2,2,0,0,0-2,2V22a2,2,0,0,0,2,2H18a2,2,0,0,0,2-2V8Z"/><line x1="10" y1="12" x2="14" y2="12"/></svg>
|
||||||
|
<svg *ngIf="icon==='box'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8v13a2 2 0 01-2 2H5a2 2 0 01-2-2V8"/><path d="M3 4h18l-2-2H5l-2 2z"/><line x1="10" y1="12" x2="14" y2="12"/></svg>
|
||||||
|
<svg *ngIf="icon==='file'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/></svg>
|
||||||
|
<svg *ngIf="icon==='lock'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||||
|
<svg *ngIf="icon==='lock-open'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="capitalize">{{ label }}</span>
|
||||||
|
</span>
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
import { Component, Input } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-state-chip',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
templateUrl: './state-chip.component.html'
|
||||||
|
})
|
||||||
|
export class StateChipComponent {
|
||||||
|
@Input() state!: 'publish' | 'favoris' | 'archive' | 'draft' | 'private';
|
||||||
|
@Input() value: boolean | null | undefined;
|
||||||
|
|
||||||
|
get label(): string {
|
||||||
|
switch (this.state) {
|
||||||
|
case 'publish': return 'Publié';
|
||||||
|
case 'favoris': return 'Favori';
|
||||||
|
case 'archive': return this.value ? 'Archivé' : 'Non archivé';
|
||||||
|
case 'draft': return 'Brouillon';
|
||||||
|
case 'private': return 'Privé';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get icon(): 'archive' | 'box' | 'globe' | 'heart' | 'file' | 'lock' | 'lock-open' {
|
||||||
|
if (this.state === 'archive') return this.value ? 'archive' : 'box';
|
||||||
|
if (this.state === 'publish') return 'globe';
|
||||||
|
if (this.state === 'favoris') return 'heart';
|
||||||
|
if (this.state === 'draft') return 'file';
|
||||||
|
return this.value ? 'lock' : 'lock-open';
|
||||||
|
}
|
||||||
|
}
|
||||||
242
src/app/features/note/shared/frontmatter-properties.service.ts
Normal file
242
src/app/features/note/shared/frontmatter-properties.service.ts
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Note } from '../../../../types';
|
||||||
|
import {
|
||||||
|
NoteProperties,
|
||||||
|
NotePropertyEntry,
|
||||||
|
NotePropertyStates,
|
||||||
|
NotePropertySummary,
|
||||||
|
} from './note-properties.model';
|
||||||
|
|
||||||
|
interface CacheEntry { timestamp: string | number | undefined; props: NoteProperties }
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class FrontmatterPropertiesService {
|
||||||
|
private cache = new Map<string, CacheEntry>();
|
||||||
|
|
||||||
|
get(note: Note | null | undefined): NoteProperties | null {
|
||||||
|
if (!note) return null;
|
||||||
|
const cacheKey = note.id;
|
||||||
|
const timestamp = note.updatedAt ?? note.mtime;
|
||||||
|
const cached = this.cache.get(cacheKey);
|
||||||
|
if (cached && cached.timestamp === timestamp) return cached.props;
|
||||||
|
|
||||||
|
const frontmatter = (note.frontmatter ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const consumedRawKeys = new Set<string>();
|
||||||
|
|
||||||
|
const summary: NotePropertySummary = {
|
||||||
|
title: this.toStr(frontmatter, consumedRawKeys, ['title', 'titre'], note.title),
|
||||||
|
author: this.toStr(frontmatter, consumedRawKeys, ['auteur', 'author']),
|
||||||
|
creationDate: this.toStr(frontmatter, consumedRawKeys, ['creation_date', 'creation-date', 'created', 'datecreated'], note.createdAt),
|
||||||
|
modificationDate: this.toStr(frontmatter, consumedRawKeys, ['modification_date', 'modification-date', 'updated', 'last_modified', 'last-modified'], note.updatedAt),
|
||||||
|
category: this.toStr(frontmatter, consumedRawKeys, ['catégorie', 'categorie', 'category']),
|
||||||
|
};
|
||||||
|
|
||||||
|
const tags = this.toArray(frontmatter, consumedRawKeys, ['tags', 'tag']) ?? [];
|
||||||
|
const aliases = this.toArray(frontmatter, consumedRawKeys, ['aliases', 'alias']) ?? [];
|
||||||
|
|
||||||
|
const states: NotePropertyStates = {};
|
||||||
|
this.assignState(states, 'publish', frontmatter, consumedRawKeys, ['publish', 'publié']);
|
||||||
|
this.assignState(states, 'favoris', frontmatter, consumedRawKeys, ['favoris', 'favorite', 'favourite']);
|
||||||
|
this.assignState(states, 'archive', frontmatter, consumedRawKeys, ['archive', 'archived']);
|
||||||
|
this.assignState(states, 'draft', frontmatter, consumedRawKeys, ['draft', 'brouillon']);
|
||||||
|
this.assignState(states, 'private', frontmatter, consumedRawKeys, ['private', 'privé', 'prive']);
|
||||||
|
|
||||||
|
const additional = this.buildAdditionalEntries(frontmatter, consumedRawKeys);
|
||||||
|
|
||||||
|
const props: NoteProperties = {
|
||||||
|
summary,
|
||||||
|
tags,
|
||||||
|
aliases,
|
||||||
|
states: this.hasAnyState(states) ? states : null,
|
||||||
|
additional,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.cache.set(cacheKey, { timestamp, props });
|
||||||
|
return props;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toStr(
|
||||||
|
source: Record<string, unknown>,
|
||||||
|
consumed: Set<string>,
|
||||||
|
keys: string[],
|
||||||
|
fallback?: unknown,
|
||||||
|
): string | undefined {
|
||||||
|
for (const key of keys) {
|
||||||
|
const raw = this.pick(source, consumed, key);
|
||||||
|
if (raw != null) {
|
||||||
|
const value = String(raw).trim();
|
||||||
|
if (value) return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fallback != null) {
|
||||||
|
const value = String(fallback).trim();
|
||||||
|
return value || undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toArray(
|
||||||
|
source: Record<string, unknown>,
|
||||||
|
consumed: Set<string>,
|
||||||
|
keys: string[],
|
||||||
|
): string[] | undefined {
|
||||||
|
for (const key of keys) {
|
||||||
|
const raw = this.pick(source, consumed, key);
|
||||||
|
if (raw == null) continue;
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
const normalized = raw.map(v => String(v).trim()).filter(Boolean);
|
||||||
|
if (normalized.length) return normalized;
|
||||||
|
}
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed.replace(/([a-zA-Z0-9_]+):/g, '"$1":'));
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
const normalized = parsed.map((v: unknown) => String(v).trim()).filter(Boolean);
|
||||||
|
if (normalized.length) return normalized;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore parse error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const normalized = trimmed.split(/[;,]/).map(v => v.trim()).filter(Boolean);
|
||||||
|
if (normalized.length) return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assignState(
|
||||||
|
target: NotePropertyStates,
|
||||||
|
targetKey: keyof NotePropertyStates,
|
||||||
|
source: Record<string, unknown>,
|
||||||
|
consumed: Set<string>,
|
||||||
|
keys: string[],
|
||||||
|
): void {
|
||||||
|
for (const key of keys) {
|
||||||
|
const raw = this.pick(source, consumed, key);
|
||||||
|
if (raw == null) continue;
|
||||||
|
const value = this.toBool(raw);
|
||||||
|
if (value === undefined) continue;
|
||||||
|
target[targetKey] = value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildAdditionalEntries(
|
||||||
|
source: Record<string, unknown>,
|
||||||
|
consumed: Set<string>,
|
||||||
|
): NotePropertyEntry[] {
|
||||||
|
const entries: NotePropertyEntry[] = [];
|
||||||
|
for (const [rawKey, rawValue] of Object.entries(source)) {
|
||||||
|
if (rawValue === undefined || rawValue === null) continue;
|
||||||
|
if (consumed.has(rawKey)) continue;
|
||||||
|
|
||||||
|
const formatted = this.formatAdditionalValue(rawValue);
|
||||||
|
if (!formatted) continue;
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
key: rawKey,
|
||||||
|
label: this.humanizeKey(rawKey),
|
||||||
|
displayValue: formatted.text,
|
||||||
|
multiline: formatted.multiline,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }));
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasAnyState(states: NotePropertyStates): boolean {
|
||||||
|
return Object.values(states).some(v => v !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
private pick(
|
||||||
|
source: Record<string, unknown>,
|
||||||
|
consumed: Set<string>,
|
||||||
|
key: string,
|
||||||
|
): unknown {
|
||||||
|
const normalizedKey = this.normalizeKey(key);
|
||||||
|
for (const candidate of Object.keys(source)) {
|
||||||
|
if (this.normalizeKey(candidate) === normalizedKey) {
|
||||||
|
consumed.add(candidate);
|
||||||
|
return source[candidate];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toBool(value: unknown): boolean | undefined {
|
||||||
|
if (value === null || value === undefined || value === '') return undefined;
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
if (typeof value === 'number') return value !== 0;
|
||||||
|
|
||||||
|
const normalized = String(value).trim().toLowerCase();
|
||||||
|
if (!normalized) return undefined;
|
||||||
|
const truthy = ['true', 'yes', 'y', '1', 'oui', 'vrai'];
|
||||||
|
const falsy = ['false', 'no', 'n', '0', 'non', 'faux'];
|
||||||
|
if (truthy.includes(normalized)) return true;
|
||||||
|
if (falsy.includes(normalized)) return false;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatAdditionalValue(value: unknown): { text: string; multiline: boolean } | null {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return { text: value ? 'Oui' : 'Non', multiline: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return { text: String(value), multiline: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
return { text: trimmed, multiline: trimmed.includes('\n') || trimmed.length > 48 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (!value.length) return null;
|
||||||
|
const items = value.map(item =>
|
||||||
|
typeof item === 'object' && item !== null
|
||||||
|
? JSON.stringify(item, null, 2)
|
||||||
|
: String(item)
|
||||||
|
);
|
||||||
|
const text = items.join('\n');
|
||||||
|
return { text, multiline: items.length > 1 || text.includes('\n') };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
try {
|
||||||
|
return { text: JSON.stringify(value, null, 2), multiline: true };
|
||||||
|
} catch {
|
||||||
|
return { text: String(value), multiline: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text: String(value), multiline: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
private humanizeKey(key: string): string {
|
||||||
|
const cleaned = key.replace(/[_-]+/g, ' ').trim();
|
||||||
|
if (!cleaned) return key;
|
||||||
|
return cleaned
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\b\w/g, match => match.toUpperCase())
|
||||||
|
.replace('Id', 'ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeKey(input: string): string {
|
||||||
|
return input
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-');
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/app/features/note/shared/note-properties.model.ts
Normal file
30
src/app/features/note/shared/note-properties.model.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
export interface NoteProperties {
|
||||||
|
summary: NotePropertySummary;
|
||||||
|
tags: string[];
|
||||||
|
aliases: string[];
|
||||||
|
states: NotePropertyStates | null;
|
||||||
|
additional: NotePropertyEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotePropertySummary {
|
||||||
|
title?: string;
|
||||||
|
author?: string;
|
||||||
|
creationDate?: string;
|
||||||
|
modificationDate?: string;
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotePropertyStates {
|
||||||
|
publish?: boolean;
|
||||||
|
favoris?: boolean;
|
||||||
|
archive?: boolean;
|
||||||
|
draft?: boolean;
|
||||||
|
private?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotePropertyEntry {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
displayValue: string;
|
||||||
|
multiline?: boolean;
|
||||||
|
}
|
||||||
@ -13,20 +13,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@if (getFrontmatterKeys(note.frontmatter).length > 0) {
|
|
||||||
<div class="mb-8 rounded-xl border border-border bg-card p-4 shadow-subtle">
|
|
||||||
<h3 class="mb-2 text-sm font-semibold uppercase tracking-wide text-text-muted">Métadonnées</h3>
|
|
||||||
<div class="grid grid-cols-2 gap-3 text-sm md:grid-cols-4">
|
|
||||||
@for (key of getFrontmatterKeys(note.frontmatter); track key) {
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<span class="text-xs uppercase tracking-wide text-text-muted">{{ key }}</span>
|
|
||||||
<span class="font-medium text-text-main">{{ note.frontmatter[key] }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<article class="prose prose-lg prose-headings:text-text-main prose-p:text-text-main max-w-none dark:prose-invert" [innerHTML]="noteHtmlContent()">
|
<article class="prose prose-lg prose-headings:text-text-main prose-p:text-text-main max-w-none dark:prose-invert" [innerHTML]="noteHtmlContent()">
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|||||||
@ -34,17 +34,6 @@ type MathJaxInstance = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type MetadataEntryType =
|
|
||||||
| 'text'
|
|
||||||
| 'date'
|
|
||||||
| 'email'
|
|
||||||
| 'url'
|
|
||||||
| 'number'
|
|
||||||
| 'boolean'
|
|
||||||
| 'image'
|
|
||||||
| 'list'
|
|
||||||
| 'object';
|
|
||||||
|
|
||||||
export interface WikiLinkActivation {
|
export interface WikiLinkActivation {
|
||||||
target: string;
|
target: string;
|
||||||
heading?: string;
|
heading?: string;
|
||||||
@ -53,17 +42,6 @@ export interface WikiLinkActivation {
|
|||||||
alias: string;
|
alias: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MetadataEntry {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
icon: string;
|
|
||||||
type: MetadataEntryType;
|
|
||||||
displayValue: string;
|
|
||||||
linkHref?: string;
|
|
||||||
imageUrl?: string;
|
|
||||||
booleanValue?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-note-viewer',
|
selector: 'app-note-viewer',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@ -176,45 +154,6 @@ interface MetadataEntry {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (metadataEntries().length > 0) {
|
|
||||||
<aside class="metadata-panel not-prose">
|
|
||||||
<div
|
|
||||||
class="metadata-panel__grid"
|
|
||||||
[class.is-expanded]="metadataExpanded()"
|
|
||||||
[attr.id]="metadataListId()"
|
|
||||||
aria-live="polite">
|
|
||||||
|
|
||||||
@for (entry of metadataVisibleEntries(); track entry.key) {
|
|
||||||
<div class="metadata-panel__item" [attr.data-type]="entry.type">
|
|
||||||
<div class="metadata-panel__icon" aria-hidden="true">{{ entry.icon }}</div>
|
|
||||||
<div class="metadata-panel__details">
|
|
||||||
@if (entry.type === 'image' && entry.imageUrl) {
|
|
||||||
<img class="metadata-panel__image" [src]="entry.imageUrl" [alt]="entry.label" loading="lazy">
|
|
||||||
} @else if ((entry.type === 'url' || entry.type === 'email') && entry.linkHref) {
|
|
||||||
<a class="metadata-panel__link" [href]="entry.linkHref" target="_blank" rel="noopener noreferrer">{{ entry.displayValue }}</a>
|
|
||||||
} @else if (entry.type === 'boolean') {
|
|
||||||
<span class="metadata-panel__boolean" [attr.data-value]="entry.booleanValue ? 'true' : 'false'">
|
|
||||||
{{ entry.displayValue }}
|
|
||||||
</span>
|
|
||||||
} @else {
|
|
||||||
<span class="metadata-panel__text">{{ entry.displayValue }}</span>
|
|
||||||
}
|
|
||||||
<div class="metadata-panel__label">{{ entry.label }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (metadataEntries().length > maxMetadataPreviewItems) {
|
|
||||||
<div class="mt-3">
|
|
||||||
<button type="button" class="btn-standard-sm" (click)="toggleMetadataPanel()">
|
|
||||||
{{ metadataExpanded() ? translate('metadata.collapse') : translate('metadata.showAll') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</aside>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="not-prose flex items-center gap-3 text-sm text-text-muted my-4">
|
<div class="not-prose flex items-center gap-3 text-sm text-text-muted my-4">
|
||||||
<span class="inline-flex items-center gap-1">
|
<span class="inline-flex items-center gap-1">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
@ -385,16 +324,12 @@ export class NoteViewerComponent implements OnDestroy {
|
|||||||
private mermaidLib: MermaidLib | null = null;
|
private mermaidLib: MermaidLib | null = null;
|
||||||
private mathRenderScheduled = false;
|
private mathRenderScheduled = false;
|
||||||
private mathJaxLoader: Promise<MathJaxInstance> | null = null;
|
private mathJaxLoader: Promise<MathJaxInstance> | null = null;
|
||||||
private readonly dateFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' });
|
|
||||||
private readonly metadataKeysToExclude = new Set(['tags', 'tag', 'keywords']);
|
|
||||||
private attachmentErrorCleanup: (() => void) | null = null;
|
private attachmentErrorCleanup: (() => void) | null = null;
|
||||||
private attachmentHandlersScheduled = false;
|
private attachmentHandlersScheduled = false;
|
||||||
private wikiLinkHandlersScheduled = false;
|
private wikiLinkHandlersScheduled = false;
|
||||||
private previewOpenSub: Subscription | null = null;
|
private previewOpenSub: Subscription | null = null;
|
||||||
|
|
||||||
readonly metadataExpanded = signal(false);
|
|
||||||
readonly menuOpen = signal(false);
|
readonly menuOpen = signal(false);
|
||||||
readonly maxMetadataPreviewItems = 3;
|
|
||||||
readonly copyStatus = signal('');
|
readonly copyStatus = signal('');
|
||||||
|
|
||||||
// Edition state
|
// Edition state
|
||||||
@ -423,29 +358,6 @@ export class NoteViewerComponent implements OnDestroy {
|
|||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
|
|
||||||
metadataEntries = computed<MetadataEntry[]>(() => {
|
|
||||||
const frontmatter = this.note().frontmatter ?? {};
|
|
||||||
const keys = this.getFrontmatterKeys(frontmatter);
|
|
||||||
const entries: MetadataEntry[] = [];
|
|
||||||
for (const key of keys) {
|
|
||||||
if (this.shouldSkipMetadataKey(key)) continue;
|
|
||||||
const rawValue = frontmatter[key];
|
|
||||||
const entry = this.buildMetadataEntry(key, rawValue);
|
|
||||||
if (entry) entries.push(entry);
|
|
||||||
}
|
|
||||||
return entries;
|
|
||||||
});
|
|
||||||
|
|
||||||
metadataVisibleEntries = computed<MetadataEntry[]>(() => {
|
|
||||||
const entries = this.metadataEntries();
|
|
||||||
if (entries.length <= this.maxMetadataPreviewItems) return entries;
|
|
||||||
return this.metadataExpanded() ? entries : entries.slice(0, this.maxMetadataPreviewItems);
|
|
||||||
});
|
|
||||||
|
|
||||||
readonly metadataListId = computed<string>(() => {
|
|
||||||
const rawId = this.note().id ?? this.note().title ?? 'metadata';
|
|
||||||
return `metadata-list-${this.slugify(`${rawId}`)}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
@ -582,7 +494,7 @@ export class NoteViewerComponent implements OnDestroy {
|
|||||||
const frontmatter = this.note().frontmatter ?? {};
|
const frontmatter = this.note().frontmatter ?? {};
|
||||||
const authorValue = frontmatter['author'] ?? frontmatter['auteur'];
|
const authorValue = frontmatter['author'] ?? frontmatter['auteur'];
|
||||||
if (!authorValue) return null;
|
if (!authorValue) return null;
|
||||||
return this.coerceToString(authorValue).trim() || null;
|
return String(authorValue).trim() || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
@ -686,10 +598,6 @@ export class NoteViewerComponent implements OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getFrontmatterKeys(frontmatter: { [key: string]: any }): string[] {
|
|
||||||
return Object.keys(frontmatter).filter(key => key !== 'tags' && key !== 'aliases' && key !== 'mtime');
|
|
||||||
}
|
|
||||||
|
|
||||||
formatBacklinkId(id: string): string {
|
formatBacklinkId(id: string): string {
|
||||||
return id.replace(/-/g, ' ').replace(/\b\w/g, char => char.toUpperCase());
|
return id.replace(/-/g, ' ').replace(/\b\w/g, char => char.toUpperCase());
|
||||||
}
|
}
|
||||||
@ -943,203 +851,6 @@ export class NoteViewerComponent implements OnDestroy {
|
|||||||
return this.mathJaxLoader;
|
return this.mathJaxLoader;
|
||||||
}
|
}
|
||||||
|
|
||||||
private toggleMetadataPanel(): void {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const currentScroll = window.scrollY;
|
|
||||||
this.metadataExpanded.update(expanded => !expanded);
|
|
||||||
queueMicrotask(() => window.scrollTo({ top: currentScroll, behavior: 'auto' }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.metadataExpanded.update(expanded => !expanded);
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildMetadataEntry(key: string, rawValue: unknown): MetadataEntry | null {
|
|
||||||
if (rawValue === undefined || rawValue === null) return null;
|
|
||||||
if (typeof rawValue === 'string' && rawValue.trim().length === 0) return null;
|
|
||||||
|
|
||||||
const normalizedKey = key.toLowerCase();
|
|
||||||
const label = this.formatMetadataLabel(key);
|
|
||||||
const baseString = this.coerceToString(rawValue);
|
|
||||||
const entry: MetadataEntry = { key, label, icon: '📝', type: 'text', displayValue: baseString };
|
|
||||||
|
|
||||||
if (Array.isArray(rawValue)) {
|
|
||||||
const parts = rawValue.map(v => this.coerceToString(v)).filter(Boolean);
|
|
||||||
if (!parts.length) return null;
|
|
||||||
entry.type = 'list';
|
|
||||||
entry.icon = '🧾';
|
|
||||||
entry.displayValue = parts.join(', ');
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rawValue instanceof Date) {
|
|
||||||
if (Number.isNaN(rawValue.getTime())) return null;
|
|
||||||
entry.type = 'date';
|
|
||||||
entry.icon = '📅';
|
|
||||||
entry.displayValue = this.dateFormatter.format(rawValue);
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedDate = this.tryParseDate(rawValue);
|
|
||||||
if (parsedDate && (normalizedKey.includes('date') || normalizedKey.includes('time') || normalizedKey.includes('birth'))) {
|
|
||||||
entry.type = 'date';
|
|
||||||
entry.icon = '📅';
|
|
||||||
entry.displayValue = this.dateFormatter.format(parsedDate);
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isBooleanLike(rawValue)) {
|
|
||||||
entry.type = 'boolean';
|
|
||||||
entry.icon = '☑️';
|
|
||||||
const value = this.toBoolean(rawValue);
|
|
||||||
entry.booleanValue = value;
|
|
||||||
entry.displayValue = value ? 'Oui' : 'Non';
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof rawValue === 'number' || (!Number.isNaN(Number(baseString)) && baseString.trim() !== '')) {
|
|
||||||
entry.type = 'number';
|
|
||||||
entry.icon = '🔢';
|
|
||||||
entry.displayValue = baseString;
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.looksLikeEmail(baseString) || normalizedKey.includes('email')) {
|
|
||||||
entry.type = 'email';
|
|
||||||
entry.icon = '✉️';
|
|
||||||
entry.displayValue = baseString;
|
|
||||||
entry.linkHref = `mailto:${baseString}`;
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.looksLikeImageUrl(baseString) || /(image|photo|avatar|picture|cover)/.test(normalizedKey)) {
|
|
||||||
const resolved = this.ensureUrlProtocol(baseString);
|
|
||||||
if (resolved) {
|
|
||||||
entry.type = 'image';
|
|
||||||
entry.icon = '🖼️';
|
|
||||||
entry.imageUrl = resolved;
|
|
||||||
entry.displayValue = baseString;
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.looksLikeUrl(baseString) || /(url|link|website)/.test(normalizedKey)) {
|
|
||||||
const resolved = this.ensureUrlProtocol(baseString);
|
|
||||||
if (resolved) {
|
|
||||||
entry.type = 'url';
|
|
||||||
entry.icon = '🔗';
|
|
||||||
entry.displayValue = baseString;
|
|
||||||
entry.linkHref = resolved;
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof rawValue === 'object') {
|
|
||||||
try {
|
|
||||||
entry.type = 'object';
|
|
||||||
entry.icon = '🧩';
|
|
||||||
entry.displayValue = JSON.stringify(rawValue, null, 2);
|
|
||||||
return entry;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
entry.icon = '📝';
|
|
||||||
entry.type = 'text';
|
|
||||||
entry.displayValue = baseString;
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatMetadataLabel(key: string): string {
|
|
||||||
return key
|
|
||||||
.replace(/[_-]+/g, ' ')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim()
|
|
||||||
.replace(/^(\w)/, (_, first) => first.toUpperCase())
|
|
||||||
.replace(/\b(\w)/g, m => m.toUpperCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
private coerceToString(value: unknown): string {
|
|
||||||
if (value === null || value === undefined) return '';
|
|
||||||
if (typeof value === 'string') return value.trim();
|
|
||||||
if (typeof value === 'number' || typeof value === 'boolean') return `${value}`;
|
|
||||||
if (value instanceof Date) return this.dateFormatter.format(value);
|
|
||||||
try {
|
|
||||||
return JSON.stringify(value);
|
|
||||||
} catch {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private looksLikeEmail(value: string): boolean {
|
|
||||||
if (!value) return false;
|
|
||||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private looksLikeUrl(value: string): boolean {
|
|
||||||
if (!value) return false;
|
|
||||||
if (/^https?:\/\//i.test(value)) return true;
|
|
||||||
if (value.startsWith('www.')) return true;
|
|
||||||
try {
|
|
||||||
new URL(value);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private ensureUrlProtocol(value: string): string | null {
|
|
||||||
if (!value) return null;
|
|
||||||
try {
|
|
||||||
const url = value.startsWith('http') ? new URL(value) : new URL(`https://${value}`);
|
|
||||||
return url.toString();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private looksLikeImageUrl(value: string): boolean {
|
|
||||||
if (!value) return false;
|
|
||||||
return /(\.(png|jpe?g|gif|svg|webp|avif|heic|heif)$)/i.test(value.split('?')[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private tryParseDate(value: unknown): Date | null {
|
|
||||||
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
|
|
||||||
if (typeof value !== 'string') return null;
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed) return null;
|
|
||||||
const parsed = Date.parse(trimmed);
|
|
||||||
if (Number.isNaN(parsed)) return null;
|
|
||||||
return new Date(parsed);
|
|
||||||
}
|
|
||||||
|
|
||||||
private isBooleanLike(value: unknown): boolean {
|
|
||||||
if (typeof value === 'boolean') return true;
|
|
||||||
if (typeof value === 'number') return value === 0 || value === 1;
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
const normalized = value.trim().toLowerCase();
|
|
||||||
return ['true', 'false', 'oui', 'non', 'yes', 'no', '1', '0'].includes(normalized);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private translate(key: 'metadata.showAll' | 'metadata.collapse'): string {
|
|
||||||
const translations: Record<typeof key, string> = {
|
|
||||||
'metadata.showAll': 'Afficher tout',
|
|
||||||
'metadata.collapse': 'Réduire',
|
|
||||||
};
|
|
||||||
return translations[key] ?? key;
|
|
||||||
}
|
|
||||||
|
|
||||||
private shouldSkipMetadataKey(key: string): boolean {
|
|
||||||
const normalized = key.trim().toLowerCase();
|
|
||||||
return this.metadataKeysToExclude.has(normalized);
|
|
||||||
}
|
|
||||||
|
|
||||||
private slugify(value: string): string {
|
|
||||||
return value.toLowerCase().trim().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-');
|
|
||||||
}
|
|
||||||
|
|
||||||
private toBoolean(value: unknown): boolean {
|
private toBoolean(value: unknown): boolean {
|
||||||
if (typeof value === 'boolean') return value;
|
if (typeof value === 'boolean') return value;
|
||||||
if (typeof value === 'number') return value !== 0;
|
if (typeof value === 'number') return value !== 0;
|
||||||
|
|||||||
@ -5,11 +5,7 @@ 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: markdown
|
catégorie: markdown
|
||||||
tags:
|
tags:
|
||||||
- tag1
|
- allo
|
||||||
- tag2
|
|
||||||
- tag3
|
|
||||||
- tag4
|
|
||||||
- markdown
|
|
||||||
aliases:
|
aliases:
|
||||||
- nouveau
|
- nouveau
|
||||||
status: en-cours
|
status: en-cours
|
||||||
@ -21,9 +17,15 @@ archive: true
|
|||||||
draft: true
|
draft: true
|
||||||
private: true
|
private: true
|
||||||
---
|
---
|
||||||
# Nouveau-markdown
|
# Test 1 Markdown
|
||||||
|
|
||||||
#tag1 #tag2 #tag3 #tag4
|
## Titres
|
||||||
|
|
||||||
|
# Niveau 1
|
||||||
|
#tag1 #tag2 #test #test2
|
||||||
|
|
||||||
|
|
||||||
|
# Nouveau-markdown
|
||||||
|
|
||||||
## sous-titre
|
## sous-titre
|
||||||
- [ ] allo
|
- [ ] allo
|
||||||
@ -32,6 +34,8 @@ private: true
|
|||||||
|
|
||||||
## sous-titre 2
|
## sous-titre 2
|
||||||
|
|
||||||
|
#tag1 #tag2 #tag3 #tag4
|
||||||
|
|
||||||
## sous-titre 3
|
## sous-titre 3
|
||||||
|
|
||||||
## sous-titre 4
|
## sous-titre 4
|
||||||
|
|||||||
@ -5,11 +5,7 @@ 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: markdown
|
catégorie: markdown
|
||||||
tags:
|
tags:
|
||||||
- tag1
|
- allo
|
||||||
- tag2
|
|
||||||
- tag3
|
|
||||||
- tag4
|
|
||||||
- markdown
|
|
||||||
aliases:
|
aliases:
|
||||||
- nouveau
|
- nouveau
|
||||||
status: en-cours
|
status: en-cours
|
||||||
@ -21,17 +17,25 @@ archive: true
|
|||||||
draft: true
|
draft: true
|
||||||
private: true
|
private: true
|
||||||
---
|
---
|
||||||
|
# Test 1 Markdown
|
||||||
|
|
||||||
|
## Titres
|
||||||
|
|
||||||
|
# Niveau 1
|
||||||
|
#tag1 #tag2 #test #test2
|
||||||
|
|
||||||
|
|
||||||
# Nouveau-markdown
|
# Nouveau-markdown
|
||||||
|
|
||||||
#tag1 #tag2 #tag3 #tag4
|
|
||||||
|
|
||||||
## sous-titre
|
## sous-titre
|
||||||
- [] allo
|
- [ ] allo
|
||||||
- [] toto
|
- [ ] toto
|
||||||
- [] tata
|
- [ ] tata
|
||||||
|
|
||||||
## sous-titre 2
|
## sous-titre 2
|
||||||
|
|
||||||
|
#tag1 #tag2 #tag3 #tag4
|
||||||
|
|
||||||
## sous-titre 3
|
## sous-titre 3
|
||||||
|
|
||||||
## sous-titre 4
|
## sous-titre 4
|
||||||
|
|||||||
@ -4,12 +4,7 @@ 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
|
|
||||||
- tag1
|
|
||||||
- tag3
|
|
||||||
aliases: []
|
aliases: []
|
||||||
status: en-cours
|
status: en-cours
|
||||||
publish: false
|
publish: false
|
||||||
@ -21,4 +16,6 @@ draft: false
|
|||||||
private: false
|
private: false
|
||||||
tag: testTag
|
tag: testTag
|
||||||
---
|
---
|
||||||
Ceci est la page 1
|
# Title
|
||||||
|
#tag1 #tag2
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,12 @@ 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:
|
||||||
|
- accueil
|
||||||
|
- markdown
|
||||||
|
- bruno
|
||||||
|
- tag1
|
||||||
|
- tag3
|
||||||
aliases: []
|
aliases: []
|
||||||
status: en-cours
|
status: en-cours
|
||||||
publish: false
|
publish: false
|
||||||
@ -14,11 +20,7 @@ archive: false
|
|||||||
draft: false
|
draft: false
|
||||||
private: false
|
private: false
|
||||||
tag: testTag
|
tag: testTag
|
||||||
tags:
|
|
||||||
- accueil
|
|
||||||
- markdown
|
|
||||||
- bruno
|
|
||||||
- tag1
|
|
||||||
- tag3
|
|
||||||
---
|
---
|
||||||
Ceci est la page 1
|
# Title
|
||||||
|
#tag1 #tag2
|
||||||
|
|
||||||
|
|||||||
@ -27,13 +27,12 @@ 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
|
||||||
---
|
---
|
||||||
#tag1 #tag2 #test #test2
|
|
||||||
|
|
||||||
# Test 1 Markdown
|
# Test 1 Markdown
|
||||||
|
|
||||||
## Titres
|
## Titres
|
||||||
|
|
||||||
# Niveau 1
|
# Niveau 1
|
||||||
|
#tag1 #tag2 #test #test2
|
||||||
|
|
||||||
## Niveau 2
|
## Niveau 2
|
||||||
|
|
||||||
|
|||||||
@ -27,13 +27,13 @@ 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
|
||||||
---
|
---
|
||||||
#tag1 #tag2 #test #test2
|
|
||||||
|
|
||||||
# Test 1 Markdown
|
# Test 1 Markdown
|
||||||
|
|
||||||
## Titres
|
## Titres
|
||||||
|
|
||||||
# Niveau 1
|
# Niveau 1
|
||||||
|
#tag1 #tag2 #test #test2
|
||||||
|
|
||||||
## Niveau 2
|
## Niveau 2
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user