feat: add document properties popover with frontmatter metadata
This commit is contained in:
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user