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
@@ -13,20 +13,6 @@
</div>
</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>
@@ -34,17 +34,6 @@ type MathJaxInstance = {
};
};
type MetadataEntryType =
| 'text'
| 'date'
| 'email'
| 'url'
| 'number'
| 'boolean'
| 'image'
| 'list'
| 'object';
export interface WikiLinkActivation {
target: string;
heading?: string;
@@ -53,17 +42,6 @@ export interface WikiLinkActivation {
alias: string;
}
interface MetadataEntry {
key: string;
label: string;
icon: string;
type: MetadataEntryType;
displayValue: string;
linkHref?: string;
imageUrl?: string;
booleanValue?: boolean;
}
@Component({
selector: 'app-note-viewer',
standalone: true,
@@ -176,45 +154,6 @@ interface MetadataEntry {
</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">
<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">
@@ -385,16 +324,12 @@ export class NoteViewerComponent implements OnDestroy {
private mermaidLib: MermaidLib | null = null;
private mathRenderScheduled = false;
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 attachmentHandlersScheduled = false;
private wikiLinkHandlersScheduled = false;
private previewOpenSub: Subscription | null = null;
readonly metadataExpanded = signal(false);
readonly menuOpen = signal(false);
readonly maxMetadataPreviewItems = 3;
readonly copyStatus = signal('');
// Edition state
@@ -423,29 +358,6 @@ export class NoteViewerComponent implements OnDestroy {
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() {
effect(() => {
@@ -582,7 +494,7 @@ export class NoteViewerComponent implements OnDestroy {
const frontmatter = this.note().frontmatter ?? {};
const authorValue = frontmatter['author'] ?? frontmatter['auteur'];
if (!authorValue) return null;
return this.coerceToString(authorValue).trim() || null;
return String(authorValue).trim() || null;
}
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 {
return id.replace(/-/g, ' ').replace(/\b\w/g, char => char.toUpperCase());
}
@@ -943,203 +851,6 @@ export class NoteViewerComponent implements OnDestroy {
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 {
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value !== 0;