feat: add document properties popover with frontmatter metadata

This commit is contained in:
2025-10-21 11:41:39 -04:00
parent d788c9d267
commit 6e50acbd22
17 changed files with 853 additions and 339 deletions
@@ -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>
</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">
<span class="path-prefix shrink min-w-0 overflow-hidden whitespace-nowrap text-ellipsis" [title]="fullPath">
{{ 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 { debounceTime, Subject } from 'rxjs';
import { splitPathKeepFilename } from '../../../../shared/utils/path';
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({
selector: 'app-note-header',
@@ -25,6 +30,15 @@ export class NoteHeaderComponent implements AfterViewInit, OnDestroy {
private ro?: ResizeObserver;
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>) {}
ngAfterViewInit(): void {
@@ -46,6 +60,7 @@ export class NoteHeaderComponent implements AfterViewInit, OnDestroy {
ngOnDestroy(): void {
this.ro?.disconnect();
this.closePopover();
}
private applyProgressiveCollapse(): void {
@@ -126,4 +141,63 @@ export class NoteHeaderComponent implements AfterViewInit, OnDestroy {
event.preventDefault();
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';
}
}