first commit

This commit is contained in:
2025-09-27 18:13:02 -04:00
commit 6e1b5407b7
253 changed files with 289900 additions and 0 deletions
@@ -0,0 +1,31 @@
<ul class="p-2 space-y-1">
@for (node of nodes(); track node.name) {
<li>
@if (node.type === 'folder') {
@let folder = node;
<div>
<button (click)="toggleFolder(folder)" class="w-full flex items-center text-left p-1.5 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 transition-transform" [class.rotate-90]="folder.isOpen" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<span class="font-semibold text-sm">{{ folder.name }}</span>
</button>
@if (folder.isOpen) {
<div class="pl-4">
<app-file-explorer [nodes]="folder.children" [selectedNoteId]="selectedNoteId()" (fileSelected)="onFileSelected($event)"></app-file-explorer>
</div>
}
</div>
} @else {
@let file = node;
<button (click)="onFileSelected(file.id)"
class="w-full text-left p-1.5 rounded text-sm transition-colors"
[class]="file.id === selectedNoteId()
? 'bg-obs-l-accent text-white dark:bg-obs-d-accent'
: 'hover:bg-gray-200 dark:hover:bg-gray-600'">
<span class="pl-6">{{ file.name }}</span>
</button>
}
</li>
}
</ul>
@@ -0,0 +1,78 @@
import { Component, ChangeDetectionStrategy, input, output, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { VaultNode, VaultFile, VaultFolder } from '../../types';
import { VaultService } from '../../services/vault.service';
@Component({
selector: 'app-file-explorer',
template: `
<ul class="space-y-0.5">
@for(node of nodes(); track node.path) {
<li>
@if (isFolder(node)) {
@let folder = node;
<div>
<div
(click)="toggleFolder(folder)"
class="flex items-center cursor-pointer p-2 rounded my-0.5 text-sm hover:bg-obs-l-bg-main dark:hover:bg-obs-d-bg-main"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 transition-transform text-obs-l-text-muted dark:text-obs-d-text-muted" [class.rotate-90]="folder.isOpen" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 flex-shrink-0 text-obs-l-text-muted dark:text-obs-d-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path></svg>
<span class="font-semibold truncate">{{ folder.name }}</span>
</div>
@if (folder.isOpen) {
<div class="pl-5">
<app-file-explorer
[nodes]="folder.children"
[selectedNoteId]="selectedNoteId()"
(fileSelected)="onFileSelected($event)"
></app-file-explorer>
</div>
}
</div>
} @else {
@let file = node;
<div
(click)="onFileSelected(file.id)"
class="flex items-center cursor-pointer p-2 rounded my-0.5 text-sm ml-5"
[class.bg-obs-l-bg-main]="selectedNoteId() === file.id"
[class.dark:bg-obs-d-bg-main]="selectedNoteId() === file.id"
[class.hover:bg-obs-l-bg-main]="selectedNoteId() !== file.id"
[class.dark:hover:bg-obs-d-bg-main]="selectedNoteId() !== file.id"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 flex-shrink-0 text-obs-l-text-muted dark:text-obs-d-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span class="truncate">{{ file.name }}</span>
</div>
}
</li>
}
</ul>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
})
export class FileExplorerComponent {
nodes = input.required<VaultNode[]>();
selectedNoteId = input<string | null>(null);
fileSelected = output<string>();
private vaultService = inject(VaultService);
onFileSelected(noteId: string) {
if(noteId) {
this.fileSelected.emit(noteId);
}
}
toggleFolder(folder: VaultFolder) {
this.vaultService.toggleFolder(folder.path);
}
isFolder(node: VaultNode): node is VaultFolder {
return node.type === 'folder';
}
}
@@ -0,0 +1,19 @@
<div class="w-full h-full p-2">
<svg #graphSvg class="w-full h-full" [attr.viewBox]="'0 0 ' + (svg()?.nativeElement.clientWidth || 300) + ' ' + (svg()?.nativeElement.clientHeight || 400)">
<g class="edges">
@for (edge of graphData().edges; track $index) {
<path [attr.d]="getEdgePath(edge)" class="stroke-obs-l-border dark:stroke-obs-d-border" stroke-width="1"></path>
}
</g>
<g class="nodes">
@for (node of simulatedNodes(); track node.id) {
<g [attr.transform]="'translate(' + node.x + ',' + node.y + ')'" (click)="selectNode(node.id)" class="cursor-pointer group">
<circle r="6" class="fill-obs-l-accent dark:fill-obs-d-accent group-hover:fill-obs-l-text-main dark:group-hover:fill-white transition-colors"></circle>
<text x="10" y="4" font-size="10" class="fill-obs-l-text-main dark:fill-obs-d-text-main opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
{{ node.label }}
</text>
</g>
}
</g>
</svg>
</div>
@@ -0,0 +1,177 @@
import { Component, ChangeDetectionStrategy, input, output, signal, viewChild, ElementRef, afterNextRender, OnDestroy, effect } from '@angular/core';
import { CommonModule } from '@angular/common';
import { GraphData, GraphEdge, GraphNode } from '../../types';
interface SimulatedNode extends GraphNode {
x: number;
y: number;
vx: number;
vy: number;
}
@Component({
selector: 'app-graph-view',
template: `
<div class="w-full h-full relative">
<svg #graphSvg class="w-full h-full">
<g class="links">
@for (edge of graphData().edges; track edge.source + '-' + edge.target) {
<path [attr.d]="getEdgePath(edge)" class="stroke-obs-l-border dark:stroke-obs-d-border" fill="none"></path>
}
</g>
<g class="nodes">
@for (node of simulatedNodes(); track node.id) {
<g [attr.transform]="'translate(' + node.x + ',' + node.y + ')'" (click)="selectNode(node.id)" class="cursor-pointer group">
<circle r="5" class="fill-current text-obs-l-accent dark:text-obs-d-accent group-hover:r-7 transition-all"></circle>
<text y="-12" text-anchor="middle" class="text-xs fill-current text-obs-l-text-main dark:text-obs-d-text-main pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">{{ node.label }}</text>
</g>
}
</g>
</svg>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
})
export class GraphViewComponent implements OnDestroy {
graphData = input.required<GraphData>();
nodeSelected = output<string>();
svg = viewChild.required<ElementRef<SVGElement>>('graphSvg');
simulatedNodes = signal<SimulatedNode[]>([]);
private animationFrameId: number | null = null;
private width = 300;
private height = 400;
constructor() {
afterNextRender(() => {
this.initSimulation();
});
effect(() => {
this.graphData();
this.initSimulation();
});
}
ngOnDestroy(): void {
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
}
}
initSimulation() {
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
const data = this.graphData();
if (!data || !this.svg()) return;
this.width = this.svg().nativeElement.clientWidth || 300;
this.height = this.svg().nativeElement.clientHeight || 400;
const nodes: SimulatedNode[] = data.nodes.map(n => ({
...n,
x: this.width / 2 + (Math.random() - 0.5) * 50,
y: this.height / 2 + (Math.random() - 0.5) * 50,
vx: 0,
vy: 0,
}));
this.simulatedNodes.set(nodes);
this.runSimulation();
}
runSimulation() {
const step = () => {
this.updatePositions();
this.animationFrameId = requestAnimationFrame(step);
};
this.animationFrameId = requestAnimationFrame(step);
}
updatePositions() {
let nodes = this.simulatedNodes();
const edges = this.graphData().edges;
if (nodes.length === 0) return;
const k = Math.sqrt((this.width * this.height) / (nodes.length + 1));
const repulsionStrength = -300;
const attractionStrength = 0.05;
const damping = 0.95;
const forceNodes = nodes.map(n => ({...n, fx: 0, fy: 0}));
for (let i = 0; i < forceNodes.length; i++) {
for (let j = i + 1; j < forceNodes.length; j++) {
const nodeA = forceNodes[i];
const nodeB = forceNodes[j];
const dx = nodeB.x - nodeA.x;
const dy = nodeB.y - nodeA.y;
let distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 1) distance = 1;
const force = (repulsionStrength * k * k) / (distance * distance);
const fx = (dx / distance) * force;
const fy = (dy / distance) * force;
nodeA.fx += fx;
nodeA.fy += fy;
nodeB.fx -= fx;
nodeB.fy -= fy;
}
}
for (const edge of edges) {
const source = forceNodes.find(n => n.id === edge.source);
const target = forceNodes.find(n => n.id === edge.target);
if (!source || !target) continue;
const dx = target.x - source.x;
const dy = target.y - source.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > 0) {
const force = attractionStrength * (distance - k);
const fx = (dx / distance) * force;
const fy = (dy / distance) * force;
source.fx += fx;
source.fy += fy;
target.fx -= fx;
target.fy -= fy;
}
}
this.simulatedNodes.update(currentNodes =>
currentNodes.map((node, i) => {
const forceNode = forceNodes[i];
let { vx, vy, x, y } = node;
const gx = (this.width / 2 - x) * 0.01;
const gy = (this.height / 2 - y) * 0.01;
vx = (vx + forceNode.fx + gx) * damping;
vy = (vy + forceNode.fy + gy) * damping;
x += vx;
y += vy;
x = Math.max(10, Math.min(this.width - 10, x));
y = Math.max(10, Math.min(this.height - 10, y));
return { ...node, x, y, vx, vy };
})
);
}
getEdgePath(edge: GraphEdge): string | null {
const nodes = this.simulatedNodes();
const sourceNode = nodes.find(n => n.id === edge.source);
const targetNode = nodes.find(n => n.id === edge.target);
if (!sourceNode || !targetNode) return null;
return `M${sourceNode.x},${sourceNode.y}L${targetNode.x},${targetNode.y}`;
}
selectNode(nodeId: string) {
this.nodeSelected.emit(nodeId);
}
}
@@ -0,0 +1,84 @@
<div class="flex flex-col gap-4">
<header class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<p class="text-xs uppercase tracking-wide text-obs-l-text-muted dark:text-obs-d-text-muted">Calendrier</p>
<h2 class="text-lg font-semibold text-obs-l-text-main dark:text-obs-d-text-main">
{{ viewDate() | calendarDate: 'monthViewTitle' : 'fr' }}
</h2>
@if (selectionLabel(); as label) {
<span class="mt-1 inline-flex items-center rounded-full bg-obs-l-bg-secondary/70 px-2 py-0.5 text-xs font-medium text-obs-l-text-muted dark:bg-obs-d-bg-secondary/60 dark:text-obs-d-text-muted">
{{ label }}
</span>
}
</div>
<div class="flex items-center justify-end gap-2">
<button
type="button"
(click)="shiftMonth(-1)"
aria-label="Mois précédent"
class="h-9 w-9 rounded-full border border-obs-l-border bg-obs-l-bg-main text-lg leading-none text-obs-l-text-main transition hover:bg-obs-l-bg-secondary dark:border-obs-d-border dark:bg-obs-d-bg-main dark:text-obs-d-text-main dark:hover:bg-obs-d-bg-secondary"
>
‹
</button>
<button
type="button"
(click)="goToToday()"
aria-label="Revenir à aujourd'hui"
class="inline-flex items-center gap-1 rounded-full border border-obs-l-border bg-obs-l-bg-main px-3 py-1.5 text-sm font-medium text-obs-l-text-main transition hover:bg-obs-l-bg-secondary dark:border-obs-d-border dark:bg-obs-d-bg-main dark:text-obs-d-text-main dark:hover:bg-obs-d-bg-secondary"
>
Aujourd'hui
</button>
<button
type="button"
(click)="shiftMonth(1)"
aria-label="Mois suivant"
class="h-9 w-9 rounded-full border border-obs-l-border bg-obs-l-bg-main text-lg leading-none text-obs-l-text-main transition hover:bg-obs-l-bg-secondary dark:border-obs-d-border dark:bg-obs-d-bg-main dark:text-obs-d-text-main dark:hover:bg-obs-d-bg-secondary"
>
›
</button>
</div>
</header>
<div class="rounded-2xl border border-obs-l-border bg-obs-l-bg-main/80 shadow-panel backdrop-blur-xs dark:border-obs-d-border dark:bg-obs-d-bg-secondary/80">
<mwl-calendar-month-view
class="calendar-compact"
[viewDate]="viewDate()"
[refresh]="refresh$"
[cellTemplate]="dayCellTemplate"
[weekStartsOn]="1"
[events]="[]"
(pointerup)="onDatePointerUp()"
></mwl-calendar-month-view>
</div>
<div class="flex flex-wrap items-center justify-center gap-3 text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">
<span class="inline-flex items-center gap-2 rounded-full bg-obs-l-bg-secondary/60 px-3 py-1 dark:bg-obs-d-bg-secondary/60">
<span class="h-2 w-2 rounded-full bg-gradient-to-r from-green-500 to-emerald-400"></span>
Création
</span>
<span class="inline-flex items-center gap-2 rounded-full bg-obs-l-bg-secondary/60 px-3 py-1 dark:bg-obs-d-bg-secondary/60">
<span class="h-2 w-2 rounded-full bg-gradient-to-r from-indigo-400 to-indigo-600"></span>
Modification
</span>
</div>
</div>
<ng-template #dayCellTemplate let-day="day">
<button
type="button"
class="day-cell"
[ngClass]="getCellClasses(day)"
(pointerdown)="onDatePointerDown(day, $event)"
(pointerenter)="onDatePointerEnter(day)"
>
<span class="day-number">{{ day.date | date: 'd' }}</span>
<div class="indicators">
@if (hasCreated(day.date)) {
<span class="indicator cal-event-created" aria-hidden="true"></span>
}
@if (hasUpdated(day.date)) {
<span class="indicator cal-event-updated" aria-hidden="true"></span>
}
</div>
</button>
</ng-template>
@@ -0,0 +1,272 @@
.calendar-card {
width: 100%;
max-width: 300px;
background: rgba(248, 250, 252, 0.94);
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 0.75rem;
padding: 0.6rem 0.65rem 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
box-shadow: 0 12px 32px -20px rgba(15, 23, 42, 0.28);
overflow: hidden;
}
.calendar-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
}
.toolbar-controls {
display: inline-flex;
align-items: center;
gap: 0.35rem;
button {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
padding: 0.25rem 0.75rem;
border-radius: 0.55rem;
border: 1px solid rgba(184, 162, 148, 0.45);
background: rgba(255, 255, 255, 0.65);
color: inherit;
font-size: 0.8rem;
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease;
&:hover,
&:focus-visible {
background: rgba(148, 163, 184, 0.22);
border-color: rgba(148, 163, 184, 0.6);
}
}
}
.calendar-legend {
display: flex;
justify-content: center;
gap: 0.75rem;
font-size: 0.6rem;
color: rgba(47, 60, 86, 0.65);
margin-top: 3.2rem;
.legend-item {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.5rem;
border-radius: 999px;
background: rgba(148, 163, 184, 0.18);
backdrop-filter: blur(4px);
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
.legend-dot.legend-created {
background: linear-gradient(135deg, #22c55e, #34d399);
}
.legend-dot.legend-updated {
background: linear-gradient(135deg, #8b5cf6, #6366f1);
}
}
.day-cell {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 1.4rem;
border-radius: 0.5rem;
padding: 0.15rem;
cursor: pointer;
transition: background 0.2s ease, border 0.2s ease;
border: 1px solid transparent;
&.is-outside {
opacity: 0.4;
}
&.is-today {
border-color: rgba(99, 102, 241, 0.55);
}
&.is-selected {
background: rgba(129, 140, 248, 0.16);
}
&.is-range-start,
&.is-range-end {
border-color: rgba(129, 140, 248, 0.75);
background: rgba(129, 140, 248, 0.28);
}
.day-number {
width: 1.2rem;
height: 1.2rem;
font-size: 0.65rem;
font-weight: 600;
line-height: 1;
border-radius: 0.45rem;
display: flex;
align-items: center;
justify-content: center;
color: inherit;
}
.indicators {
margin-top: 0.12rem;
display: flex;
flex-direction: column;
gap: 1px;
width: 100%;
.indicator {
display: block;
height: 2px;
border-radius: 9999px;
margin: 0.25px 0;
box-shadow: 0 0 4px rgba(15, 23, 42, 0.1);
}
.indicator.cal-event-created {
background: linear-gradient(90deg, #22c55e, #34d399);
}
.indicator.cal-event-updated {
background: linear-gradient(90deg, #8b5cf6, #6366f1);
}
}
}
:host-context(.dark) {
.calendar-card {
// background: rgba(34, 39, 54, 0.96);
background: #202123;
border-color: rgba(89, 102, 126, 0.5);
max-width: 280px;
padding: 0.6rem 0.65rem 1rem;
}
.toolbar-controls button {
background: #232527;
border-color: rgba(99, 102, 241, 0.35);
&:hover,
&:focus-visible {
background: rgba(129, 140, 248, 0.22);
border-color: rgba(129, 140, 248, 0.55);
}
}
.toolbar-controls {
gap: 0.25rem;
}
.toolbar-title {
gap: 0.1rem;
font-size: 0.8rem;
}
.toolbar-title .selection-label {
color: rgba(191, 214, 255, 0.75);
}
.calendar-legend {
color: rgba(210, 219, 237, 0.8);
gap: 0.85rem;
.legend-item {
background: rgba(99, 102, 241, 0.22);
}
}
.day-cell {
min-height: 1.25rem;
padding: 0.12rem;
&.is-selected {
background: rgba(129, 140, 248, 0.28);
}
&.is-range-start,
&.is-range-end {
background: rgba(129, 140, 248, 0.38);
border-color: rgba(167, 139, 250, 0.75);
box-shadow: 0 0 0 1px rgba(22, 163, 74, 0.25);
}
}
}
:host ::ng-deep .calendar-compact {
max-height: 172px;
min-height: 160px;
}
:host ::ng-deep .calendar-compact .cal-week {
gap: 0.18rem;
}
:host ::ng-deep .calendar-compact .cal-month-view {
background: rgba(248, 250, 252, 0.95);
border: 1px solid rgba(203, 213, 225, 0.45);
}
:host ::ng-deep .calendar-compact .cal-header {
padding: 0.15rem 0.3rem;
background: transparent;
}
:host ::ng-deep .calendar-compact .cal-header .cal-cell {
font-size: 0;
text-transform: uppercase;
padding: 0.15rem 0;
text-align: center;
}
:host ::ng-deep .calendar-compact .cal-header .cal-cell::first-letter {
font-size: 0.55rem;
font-weight: 700;
display: inline-block;
color: rgba(79, 70, 229, 0.85);
}
:host ::ng-deep .calendar-compact .cal-cell {
background: rgba(255, 255, 255, 0.85);
border-radius: 0.5rem;
border: 1px solid rgba(226, 232, 240, 0.6);
padding: 0.15rem;
}
:host-context(.dark) ::ng-deep .calendar-compact .cal-month-view {
// background: rgba(37, 44, 63, 0.95);
background: #2B2D46;
border-color: rgba(88, 103, 132, 0.55);
}
:host-context(.dark) ::ng-deep .calendar-compact .cal-cell {
// background: rgba(48, 58, 78, 0.92);
background: #2D2E30;
border-color: rgba(107, 114, 128, 0.4);
color: rgba(220, 230, 246, 0.95);
}
:host-context(.dark) ::ng-deep .calendar-compact .cal-day-number {
color: rgba(236, 253, 245, 0.9);
}
:host-context(.dark) ::ng-deep .calendar-compact .cal-header .cal-cell::first-letter {
color: rgba(167, 139, 250, 0.85);
}
@@ -0,0 +1,324 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
HostListener,
computed,
inject,
output,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import {
CalendarMonthViewComponent,
CalendarMonthViewDay,
CalendarDatePipe,
DateAdapter,
provideCalendar,
} from 'angular-calendar';
import { adapterFactory } from 'angular-calendar/date-adapters/date-fns';
import { MarkdownCalendarService } from '../../services/markdown-calendar.service';
import { FileMetadata } from '../../types';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Observable, Subject } from 'rxjs';
interface DayBucket {
created: FileMetadata[];
updated: FileMetadata[];
}
interface DateRange {
start: Date | null;
end: Date | null;
}
@Component({
selector: 'app-markdown-calendar',
standalone: true,
imports: [CommonModule, CalendarMonthViewComponent, CalendarDatePipe],
templateUrl: './markdown-calendar.component.html',
styleUrls: ['./markdown-calendar.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
provideCalendar({
provide: DateAdapter,
useFactory: adapterFactory,
}),
],
})
export class MarkdownCalendarComponent {
private readonly calendarService = inject(MarkdownCalendarService);
private readonly destroyRef = inject(DestroyRef);
readonly fileSelected = output<string>();
readonly searchResultsChange = output<FileMetadata[]>();
readonly searchStateChange = output<'idle' | 'loading' | 'error'>();
readonly searchErrorChange = output<string | null>();
readonly selectionSummaryChange = output<string | null>();
readonly requestSearchPanel = output<void>();
readonly viewDate = signal(this.normalizeDay(new Date()));
readonly metadata = signal<FileMetadata[]>([]);
readonly dayBuckets = computed<Map<string, DayBucket>>(() =>
this.calendarService.groupMetadataByDay(this.metadata())
);
readonly isLoading = signal<boolean>(false);
readonly loadError = signal<string | null>(null);
readonly searchResults = signal<FileMetadata[]>([]);
readonly isSearching = signal<boolean>(false);
readonly searchError = signal<string | null>(null);
readonly selectedRange = signal<DateRange>({ start: null, end: null });
readonly selectionLabel = computed(() => this.buildSelectionLabel());
readonly refresh$ = new Subject<void>();
private dragAnchor: Date | null = null;
private searchRequestId = 0;
constructor() {
this.loadMetadata();
}
// --- Lifecycle & data loading ------------------------------------------------
private loadMetadata(): void {
this.isLoading.set(true);
this.loadError.set(null);
this.calendarService
.getFilesMetadata()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (data) => {
this.metadata.set(data);
this.isLoading.set(false);
this.refresh$.next();
},
error: () => {
this.isLoading.set(false);
this.loadError.set('Impossible de charger les métadonnées des fichiers.');
},
});
}
// --- Calendar interactions ---------------------------------------------------
shiftMonth(offset: number): void {
const current = this.viewDate();
const shifted = new Date(current);
shifted.setMonth(current.getMonth() + offset, 1);
this.viewDate.set(this.normalizeDay(shifted));
this.refresh$.next();
}
goToToday(): void {
this.viewDate.set(this.normalizeDay(new Date()));
this.refresh$.next();
}
onDatePointerDown(day: CalendarMonthViewDay<Date>, event: PointerEvent): void {
if (event.button !== 0) {
return;
}
event.preventDefault();
const date = this.normalizeDay(day.date);
this.dragAnchor = date;
this.selectedRange.set({ start: date, end: date });
this.isSearching.set(false);
this.searchError.set(null);
this.searchResults.set([]);
this.searchRequestId++;
this.searchResultsChange.emit([]);
this.searchStateChange.emit('idle');
this.searchErrorChange.emit(null);
this.emitSelectionSummary();
this.requestSearchPanel.emit();
this.refresh$.next();
}
onDatePointerEnter(day: CalendarMonthViewDay<Date>): void {
if (!this.dragAnchor) {
return;
}
const hovered = this.normalizeDay(day.date);
const range = this.normalizeRange(this.dragAnchor, hovered);
this.selectedRange.set(range);
this.emitSelectionSummary();
this.requestSearchPanel.emit();
this.refresh$.next();
}
@HostListener('document:pointerup')
finishRangeSelection(): void {
this.finalizeSelection();
}
onDatePointerUp(): void {
this.finalizeSelection();
}
private finalizeSelection(): void {
if (!this.dragAnchor) {
return;
}
const currentRange = this.selectedRange();
const { start, end } = currentRange;
this.dragAnchor = null;
if (!start || !end) {
return;
}
if (start.getTime() === end.getTime()) {
this.fetchResultsForSingleDate(start);
} else {
this.fetchResultsForRange(start, end);
}
this.emitSelectionSummary();
this.refresh$.next();
}
// --- Result handling ---------------------------------------------------------
private fetchResultsForSingleDate(date: Date): void {
this.runSearch(this.calendarService.searchFilesByDate(date));
}
private fetchResultsForRange(start: Date, end: Date): void {
this.runSearch(this.calendarService.searchFilesByDateRange(start, end));
}
private runSearch(source$: Observable<FileMetadata[]>): void {
const token = ++this.searchRequestId;
this.isSearching.set(true);
this.searchError.set(null);
this.searchStateChange.emit('loading');
this.searchErrorChange.emit(null);
source$.subscribe({
next: (files: FileMetadata[]) => {
if (token !== this.searchRequestId) {
return;
}
this.searchResults.set(files);
this.isSearching.set(false);
this.searchStateChange.emit('idle');
this.searchResultsChange.emit(files);
},
error: () => {
if (token !== this.searchRequestId) {
return;
}
this.isSearching.set(false);
this.searchError.set('Recherche impossible pour cette période.');
this.searchStateChange.emit('error');
this.searchErrorChange.emit('Recherche impossible pour cette période.');
this.searchResultsChange.emit([]);
this.searchResults.set([]);
},
});
}
openFile(file: FileMetadata): void {
this.fileSelected.emit(file.id);
}
trackByFileId = (_: number, file: FileMetadata) => file.id;
// --- Template helpers --------------------------------------------------------
hasCreated(date: Date): boolean {
const bucket = this.getBucketForDate(date);
return !!bucket && bucket.created.length > 0;
}
hasUpdated(date: Date): boolean {
const bucket = this.getBucketForDate(date);
return !!bucket && bucket.updated.length > 0;
}
isInSelectedRange(date: Date): boolean {
const { start, end } = this.selectedRange();
if (!start || !end) {
return false;
}
const value = this.normalizeDay(date).getTime();
return value >= start.getTime() && value <= end.getTime();
}
isRangeStart(date: Date): boolean {
const { start } = this.selectedRange();
return !!start && this.normalizeDay(date).getTime() === start.getTime();
}
isRangeEnd(date: Date): boolean {
const { end } = this.selectedRange();
return !!end && this.normalizeDay(date).getTime() === end.getTime();
}
getCellClasses(day: CalendarMonthViewDay<Date>): Record<string, boolean> {
const date = this.normalizeDay(day.date);
return {
'is-outside': !day.inMonth,
'is-today': this.isToday(date),
'is-selected': this.isInSelectedRange(date),
'is-range-start': this.isRangeStart(date),
'is-range-end': this.isRangeEnd(date),
};
}
// --- Utilities ---------------------------------------------------------------
private buildSelectionLabel(): string | null {
const { start, end } = this.selectedRange();
if (!start || !end) {
return null;
}
const format = (date: Date) =>
new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: 'short',
year: 'numeric',
}).format(date);
const startLabel = format(start);
const endLabel = format(end);
return start.getTime() === end.getTime()
? startLabel
: `${startLabel} – ${endLabel}`;
}
private isToday(date: Date): boolean {
const today = this.normalizeDay(new Date());
return date.getTime() === today.getTime();
}
private normalizeDay(date: Date): Date {
const normalized = new Date(date);
normalized.setHours(0, 0, 0, 0);
return normalized;
}
private normalizeRange(start: Date, end: Date): DateRange {
const normalizedStart = this.normalizeDay(start);
const normalizedEnd = this.normalizeDay(end);
if (normalizedStart.getTime() <= normalizedEnd.getTime()) {
return { start: normalizedStart, end: normalizedEnd };
}
return { start: normalizedEnd, end: normalizedStart };
}
private getBucketForDate(date: Date): DayBucket | undefined {
const key = this.normalizeDay(date).toDateString();
return this.dayBuckets().get(key);
}
private emitSelectionSummary(): void {
this.selectionSummaryChange.emit(this.selectionLabel());
}
}
@@ -0,0 +1,70 @@
@if(note(); as note) {
<div class="max-w-7xl mx-auto flex">
<div class="flex-grow min-w-0 max-w-4xl p-8 lg:p-12">
<header class="mb-8">
<h1 class="text-4xl font-bold text-obs-l-text-main dark:text-gray-100 mb-2">{{ note.title }}</h1>
<div class="text-sm text-obs-l-text-muted dark:text-obs-d-text-muted">
<span>Updated: {{ note.updatedAt | date:'medium' }}</span>
</div>
<div class="mt-4 flex flex-wrap gap-2">
@for(tag of note.tags; track tag) {
<span class="bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-300 text-xs font-medium px-2.5 py-1 rounded-full">#{{ tag }}</span>
}
</div>
</header>
@if (getFrontmatterKeys(note.frontmatter).length > 0) {
<div class="mb-8 p-4 border border-obs-l-border dark:border-obs-d-border rounded-lg bg-obs-l-bg-secondary dark:bg-obs-d-bg-secondary">
<h3 class="font-semibold text-obs-l-text-muted dark:text-obs-d-text-muted mb-2 text-sm">Metadata</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm">
@for (key of getFrontmatterKeys(note.frontmatter); track key) {
<div class="flex flex-col">
<span class="text-obs-l-text-muted dark:text-obs-d-text-muted capitalize">{{ key }}</span>
<span class="text-obs-l-text-main dark:text-obs-d-text-main font-medium">{{ note.frontmatter[key] }}</span>
</div>
}
</div>
</div>
}
<article class="prose dark:prose-invert prose-lg max-w-none prose-p:text-obs-l-text-main dark:prose-p:text-obs-d-text-main prose-headings:text-obs-l-text-main dark:prose-headings:text-white" [innerHTML]="noteHtmlContent()">
</article>
@if (note.backlinks.length > 0) {
<footer class="mt-12 border-t border-obs-l-border dark:border-obs-d-border pt-6">
<h2 class="text-xl font-semibold mb-4 text-obs-l-text-main dark:text-gray-300">Backlinks</h2>
<ul class="space-y-2">
@for (backlinkId of note.backlinks; track backlinkId) {
<li>
<button (click)="noteLinkClicked.emit(backlinkId)" class="text-obs-l-accent dark:text-obs-d-accent hover:underline">
{{ formatBacklinkId(backlinkId) }}
</button>
</li>
}
</ul>
</footer>
}
</div>
@if (tableOfContents().length > 0) {
<aside class="w-64 flex-shrink-0 hidden lg:block">
<div class="sticky top-0 h-screen overflow-y-auto pt-8 lg:pt-12">
<nav class="pb-8">
<h3 class="text-sm font-semibold uppercase tracking-wider text-obs-l-text-muted dark:text-obs-d-text-muted mb-4">On this page</h3>
<ul class="space-y-1">
@for(item of tableOfContents(); track item.id) {
<li>
<button (click)="scrollToHeading(item.id)"
[style.padding-left.rem]="(item.level - 1) * 1"
class="block w-full text-left py-1 text-sm rounded text-obs-l-text-muted dark:text-obs-d-text-muted hover:text-obs-l-text-main dark:hover:text-obs-d-text-main transition-colors focus:outline-none focus:text-obs-l-text-main dark:focus:text-obs-d-text-main">
{{ item.text }}
</button>
</li>
}
</ul>
</nav>
</div>
</aside>
}
</div>
}
@@ -0,0 +1,739 @@
import { Component, ChangeDetectionStrategy, ElementRef, OnDestroy, afterNextRender, computed, effect, inject, input, output, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Note } from '../../types';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import mermaid, { MermaidConfig, RenderResult } from 'mermaid';
type MermaidLib = typeof mermaid;
type MetadataEntryType = 'text' | 'date' | 'email' | 'url' | 'number' | 'boolean' | 'image' | 'list' | 'object';
interface MetadataEntry {
key: string;
label: string;
icon: string;
type: MetadataEntryType;
displayValue: string;
linkHref?: string;
imageUrl?: string;
booleanValue?: boolean;
}
@Component({
selector: 'app-note-viewer',
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="p-8 prose prose-lg dark:prose-invert max-w-none">
<div class="!mb-6 pb-2 border-b border-obs-l-border dark:border-obs-d-border">
<h1 class="!text-4xl !font-bold !mb-3">{{ note().title }}</h1>
@if (note().tags.length > 0) {
<div class="md-tag-group not-prose">
@for (tag of note().tags; track tag) {
<button
type="button"
class="md-tag-badge"
[ngClass]="tagColorClass(tag)"
data-origin="header"
[attr.data-tag]="tag"
(click)="tagClicked.emit(tag)">
#{{ tag }}
</button>
}
</div>
}
</div>
@if (frontmatterTags().length > 0) {
<div class="mb-6 md-tag-group not-prose">
@for (tag of frontmatterTags(); track tag) {
<button
type="button"
class="md-tag-badge"
[ngClass]="tagColorClass(tag)"
data-origin="frontmatter"
[attr.data-tag]="tag"
(click)="tagClicked.emit(tag)">
#{{ tag }}
</button>
}
</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">
<div class="metadata-panel__label">{{ entry.label }}</div>
@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') {
<div class="metadata-panel__value metadata-panel__value--boolean" [class.is-true]="entry.booleanValue">
<span class="metadata-panel__boolean-indicator" [class.is-checked]="entry.booleanValue"></span>
{{ entry.displayValue }}
</div>
} @else {
<div class="metadata-panel__value">{{ entry.displayValue }}</div>
}
</div>
</div>
}
</div>
@if (metadataEntries().length > maxMetadataPreviewItems) {
<div class="metadata-panel__actions">
<button
type="button"
class="metadata-panel__toggle"
(click)="toggleMetadataPanel()"
[attr.aria-controls]="metadataListId()"
[attr.aria-expanded]="metadataExpanded()">
{{ metadataExpanded() ? translate('metadata.collapse') : translate('metadata.showAll') }}
</button>
</div>
}
</aside>
}
<div [innerHTML]="sanitizedHtmlContent()"></div>
@if (note().backlinks.length > 0) {
<div class="mt-12 pt-6 border-t border-obs-l-border dark:border-obs-d-border not-prose">
<h2 class="text-2xl font-bold mb-4">Backlinks</h2>
<ul>
@for (backlinkId of note().backlinks; track backlinkId) {
<li class="mb-2">
<a
(click)="noteLinkClicked.emit(backlinkId)"
class="cursor-pointer text-obs-l-accent dark:text-obs-d-accent hover:underline"
>
{{ formatBacklinkId(backlinkId) }}
</a>
</li>
}
</ul>
</div>
}
</div>
`,
})
export class NoteViewerComponent implements OnDestroy {
note = input.required<Note>();
noteHtmlContent = input.required<string>();
noteLinkClicked = output<string>();
tagClicked = output<string>();
private readonly elementRef = inject(ElementRef<HTMLElement>);
private readonly sanitizer = inject(DomSanitizer);
private readonly tagPaletteSize = 12;
private readonly tagColorCache = new Map<string, number>();
private readonly copyFeedbackTimers = new Map<HTMLElement, number>();
private mermaidObserver: MutationObserver | null = null;
private mermaidRenderScheduled = false;
private mermaidLoader: Promise<MermaidLib> | null = null;
private mermaidLib: MermaidLib | null = null;
private readonly dateFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' });
private readonly metadataKeysToExclude = new Set(['tags', 'tag', 'keywords']);
readonly metadataExpanded = signal(false);
readonly maxMetadataPreviewItems = 3;
sanitizedHtmlContent = computed<SafeHtml>(() =>
this.sanitizer.bypassSecurityTrustHtml(this.noteHtmlContent())
);
frontmatterTags = computed<string[]>(() => {
const tags = this.note().frontmatter?.tags;
const headerTags = new Set((this.note().tags ?? []).map(tag => `${tag}`.trim().toLowerCase()).filter(Boolean));
if (!tags) {
return [];
}
if (Array.isArray(tags)) {
return Array.from(new Set(tags
.map(tag => `${tag}`.trim())
.filter(Boolean)
.filter(tag => !headerTags.has(tag.toLowerCase()))));
}
if (typeof tags === 'string') {
return tags
.split(',')
.map(tag => tag.trim())
.filter(Boolean)
.filter(tag => !headerTags.has(tag.toLowerCase()));
}
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(() => {
this.noteHtmlContent();
this.scheduleMermaidRender();
});
afterNextRender(() => {
const hostElement = this.elementRef.nativeElement as HTMLElement;
hostElement.addEventListener('click', (event: MouseEvent) => {
const target = event.target as HTMLElement | null;
if (!target) {
return;
}
const languageBadge = target.closest('button.code-block__language-badge') as HTMLButtonElement | null;
if (languageBadge) {
event.preventDefault();
this.handleCodeCopy(languageBadge);
return;
}
const inlineTagButton = target.closest('button.md-tag-badge[data-origin="inline"]') as HTMLButtonElement | null;
if (inlineTagButton) {
event.preventDefault();
const tagValue = inlineTagButton.dataset.tag;
if (tagValue) {
this.tagClicked.emit(tagValue);
}
return;
}
const anchor = target.closest('a[data-note-id]');
if (anchor) {
event.preventDefault();
const noteId = anchor.getAttribute('data-note-id');
const noteTitle = anchor.getAttribute('data-note-title');
if (noteId) {
this.noteLinkClicked.emit(noteId);
} else if (noteTitle) {
this.noteLinkClicked.emit(noteTitle.toLowerCase().replace(/\s+/g, '-'));
}
}
});
this.setupMermaidObservation();
this.scheduleMermaidRender();
});
}
ngOnDestroy(): void {
this.mermaidObserver?.disconnect();
this.mermaidObserver = null;
this.mermaidLib = null;
this.mermaidLoader = null;
}
getFrontmatterKeys(frontmatter: { [key: string]: any }): string[] {
return Object.keys(frontmatter).filter(key => key !== 'tags' && key !== 'aliases' && key !== 'mtime');
}
formatFrontmatterValue(value: any): string {
if (Array.isArray(value)) {
return value.join(', ');
}
if (typeof value === 'object' && value !== null) {
return JSON.stringify(value);
}
return `${value}`;
}
formatBacklinkId(id: string): string {
return id.replace(/-/g, ' ').replace(/\b\w/g, char => char.toUpperCase());
}
tagColorClass(tag: string): string {
if (!tag) {
return '';
}
const normalized = `${tag}`.toLowerCase();
if (this.tagColorCache.has(normalized)) {
return `md-tag-color-${this.tagColorCache.get(normalized)}`;
}
let hash = 0;
for (let i = 0; i < normalized.length; i++) {
hash = (hash * 31 + normalized.charCodeAt(i)) >>> 0;
}
const colorIndex = hash % this.tagPaletteSize;
this.tagColorCache.set(normalized, colorIndex);
return `md-tag-color-${colorIndex}`;
}
private setupMermaidObservation(): void {
if (typeof MutationObserver === 'undefined') {
return;
}
this.mermaidObserver?.disconnect();
this.mermaidObserver = new MutationObserver(() => this.scheduleMermaidRender());
this.mermaidObserver.observe(this.elementRef.nativeElement as HTMLElement, { childList: true, subtree: true });
}
private scheduleMermaidRender(): void {
if (this.mermaidRenderScheduled) {
return;
}
this.mermaidRenderScheduled = true;
queueMicrotask(() => {
this.mermaidRenderScheduled = false;
this.renderMermaidDiagrams();
});
}
private ensureMermaid(): Promise<MermaidLib> {
if (this.mermaidLib) {
return Promise.resolve(this.mermaidLib);
}
if (this.mermaidLoader) {
return this.mermaidLoader;
}
if (typeof window === 'undefined') {
return Promise.reject(new Error('Mermaid is only available in the browser environment.'));
}
this.mermaidLoader = import('mermaid')
.then(module => {
const mermaidLib = (module.default ?? module) as MermaidLib;
const prefersDark = document.documentElement.classList.contains('dark');
mermaidLib.initialize({
startOnLoad: false,
securityLevel: 'loose',
theme: prefersDark ? 'dark' : 'default',
});
this.mermaidLib = mermaidLib;
return mermaidLib;
})
.catch(error => {
this.mermaidLoader = null;
throw error;
});
return this.mermaidLoader;
}
private renderMermaidDiagrams(): void {
if (typeof window === 'undefined') {
return;
}
const hostElement = this.elementRef.nativeElement as HTMLElement;
const diagrams = hostElement.querySelectorAll<HTMLElement>('.mermaid-diagram[data-mermaid-code]');
if (!diagrams.length) {
return;
}
this.ensureMermaid()
.then(mermaidLib => {
diagrams.forEach((element, index) => {
if (element.dataset.mermaidRendered === 'true') {
return;
}
const encoded = element.dataset.mermaidCode ?? element.getAttribute('data-mermaid-code');
if (!encoded) {
return;
}
const definition = this.decodeMermaidSource(encoded);
const diagramId = `mermaid-${Date.now()}-${index}-${Math.random().toString(36).slice(2)}`;
mermaidLib.render(diagramId, definition)
.then(({ svg, bindFunctions }) => {
element.innerHTML = svg;
element.setAttribute('data-mermaid-rendered', 'true');
bindFunctions?.(element);
})
.catch(error => {
element.innerHTML = `<pre class="mermaid-error">${this.escapeHtmlForMermaid(definition)}\n${this.escapeHtmlForMermaid(error?.message ?? String(error))}</pre>`;
element.setAttribute('data-mermaid-rendered', 'error');
});
});
})
.catch(error => {
console.error('Mermaid initialization error:', error);
});
}
private decodeMermaidSource(encoded: string): string {
try {
return decodeURIComponent(encoded);
} catch {
return encoded;
}
}
private escapeHtmlForMermaid(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
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(value => this.coerceToString(value)).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, match => match.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;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
return normalized === 'true' || normalized === 'oui' || normalized === 'yes' || normalized === '1';
}
return false;
}
private handleCodeCopy(button: HTMLButtonElement): void {
const codeId = button.dataset.codeId;
if (!codeId) {
return;
}
const block = (this.elementRef.nativeElement as HTMLElement).querySelector<HTMLElement>(
`.code-block[data-code-id="${codeId}"]`
);
const codeElement = block?.querySelector<HTMLElement>('code[data-raw-code]');
const rawEncoded = codeElement?.getAttribute('data-raw-code');
if (!block || !codeElement || rawEncoded == null) {
return;
}
let decoded = '';
try {
decoded = decodeURIComponent(rawEncoded);
} catch {
decoded = rawEncoded;
}
const copyPromise = typeof navigator !== 'undefined' && navigator.clipboard?.writeText
? navigator.clipboard.writeText(decoded)
: this.fallbackCopy(decoded);
copyPromise
.then(() => this.showCopyFeedback(block, 'Copié !'))
.catch(() => this.showCopyFeedback(block, 'Copie impossible'));
}
private fallbackCopy(text: string): Promise<void> {
return new Promise((resolve, reject) => {
try {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.pointerEvents = 'none';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textarea);
if (successful) {
resolve();
} else {
reject(new Error('Copy command failed'));
}
} catch (error) {
reject(error as Error);
}
});
}
private showCopyFeedback(block: HTMLElement, message: string): void {
const feedback = block.querySelector<HTMLElement>('.code-block__copy-feedback');
if (!feedback) {
return;
}
feedback.textContent = message;
feedback.hidden = false;
block.classList.add('copied');
const existingTimer = this.copyFeedbackTimers.get(block);
if (existingTimer) {
window.clearTimeout(existingTimer);
}
const timeout = window.setTimeout(() => {
feedback.hidden = true;
block.classList.remove('copied');
this.copyFeedbackTimers.delete(block);
}, message === 'Copié !' ? 1800 : 2200);
this.copyFeedbackTimers.set(block, timeout);
}
}
@@ -0,0 +1,17 @@
<div class="p-4">
<h3 class="text-sm font-semibold text-obs-l-text-muted dark:text-obs-d-text-muted mb-3">All Tags</h3>
@if (tags().length > 0) {
<ul class="space-y-1">
@for (tag of tags(); track tag.name) {
<li>
<button (click)="tagSelected.emit(tag.name)" class="w-full flex justify-between items-center text-left p-1.5 rounded hover:bg-gray-200 dark:hover:bg-gray-600 text-sm">
<span>#{{ tag.name }}</span>
<span class="text-xs bg-gray-200 dark:bg-gray-700 rounded-full px-2 py-0.5">{{ tag.count }}</span>
</button>
</li>
}
</ul>
} @else {
<p class="text-sm text-obs-l-text-muted dark:text-obs-d-text-muted italic">No tags found in vault.</p>
}
</div>
@@ -0,0 +1,242 @@
import {
Component,
ChangeDetectionStrategy,
effect,
input,
output,
signal,
computed,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { TagInfo } from '../../types';
interface TagSection {
letter: string;
tags: TagInfo[];
}
@Component({
selector: 'app-tags-view',
imports: [CommonModule],
template: `
<div class="flex h-full flex-col gap-4 p-3">
<div class="rounded-lg border border-obs-l-border/60 bg-obs-l-bg-secondary/70 px-3 py-2 shadow-sm dark:border-obs-d-border/60 dark:bg-obs-d-bg-secondary/70">
<label class="sr-only" for="tag-search">Rechercher des tags</label>
<div class="flex items-center gap-2">
<svg class="h-4 w-4 text-obs-l-text-muted dark:text-obs-d-text-muted" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-5.2-5.2m0 0A7 7 0 105.8 5.8a7 7 0 0010 10z" />
</svg>
<input
id="tag-search"
type="search"
[value]="searchTerm()"
(input)="onSearchChange($event.target?.value ?? '')"
placeholder="Rechercher un tag..."
class="w-full bg-transparent text-sm text-obs-l-text-main outline-none placeholder:text-obs-l-text-muted dark:text-obs-d-text-main dark:placeholder:text-obs-d-text-muted"
/>
@if (searchTerm(); as value) {
@if (value.length > 0) {
<button
type="button"
class="text-xs text-obs-l-text-muted transition hover:text-obs-l-text-main dark:text-obs-d-text-muted dark:hover:text-obs-d-text-main"
aria-label="Effacer la recherche"
(click)="clearSearch()"
>
Effacer
</button>
}
}
</div>
</div>
<div class="flex flex-1 gap-3 overflow-hidden">
<div class="flex-1 overflow-y-auto pr-1">
@if (displayedSections().length > 0) {
<div class="space-y-4">
@for (section of displayedSections(); track section.letter) {
<section class="space-y-2">
<header class="flex items-center justify-between">
<h2 class="text-xs font-semibold uppercase tracking-wide text-obs-l-text-muted dark:text-obs-d-text-muted">
{{ section.letter }}
</h2>
<span class="text-[0.65rem] text-obs-l-text-muted/70 dark:text-obs-d-text-muted/70">
{{ section.tags.length }} tag(s)
</span>
</header>
<div class="flex flex-wrap gap-2">
@for (tag of section.tags; track tag.name) {
<button
type="button"
class="inline-flex items-center gap-2 rounded-full border border-obs-l-border/60 bg-obs-l-bg-main/80 px-3 py-1 text-xs font-medium text-obs-l-text-muted transition hover:bg-obs-l-bg-main dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/70 dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-main"
(click)="tagSelected.emit(tag.name)"
>
<span class="text-sm">🔖</span>
<span class="truncate">#{{ tag.name }}</span>
<span class="rounded-full bg-obs-l-bg-secondary/80 px-2 py-0.5 text-[0.65rem] text-obs-l-text-muted dark:bg-obs-d-bg-secondary/80 dark:text-obs-d-text-muted">
{{ tag.count }}
</span>
</button>
}
</div>
</section>
}
</div>
} @else {
<div class="flex h-full items-center justify-center rounded-lg border border-dashed border-obs-l-border/60 bg-obs-l-bg-secondary/50 px-4 py-6 text-center text-sm text-obs-l-text-muted dark:border-obs-d-border/60 dark:bg-obs-d-bg-secondary/50 dark:text-obs-d-text-muted">
Aucun tag ne correspond à votre recherche.
</div>
}
</div>
<nav class="sticky top-0 flex h-full w-10 flex-col items-center justify-start gap-1 self-start rounded-lg border border-obs-l-border/60 bg-obs-l-bg-secondary/70 px-2 py-3 text-xs font-medium text-obs-l-text-muted shadow-sm dark:border-obs-d-border/60 dark:bg-obs-d-bg-secondary/70 dark:text-obs-d-text-muted">
<span class="text-[0.6rem] uppercase tracking-wide text-obs-l-text-muted/70 dark:text-obs-d-text-muted/70">A–Z</span>
<div class="mt-2 grid grid-cols-1 gap-1">
@for (letter of availableLetters(); track letter) {
<button
type="button"
class="flex h-7 w-7 items-center justify-center rounded-full transition"
[class.bg-obs-l-bg-main]="activeLetter() === letter"
[class.dark:bg-obs-d-bg-main]="activeLetter() === letter"
[class.text-obs-l-text-main]="activeLetter() === letter"
[class.dark:text-obs-d-text-main]="activeLetter() === letter"
[attr.aria-pressed]="activeLetter() === letter"
(click)="onLetterClick(letter)"
>
{{ letter }}
</button>
}
</div>
</nav>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TagsViewComponent {
private readonly collator = new Intl.Collator('fr', {
sensitivity: 'base',
numeric: true,
});
readonly tags = input.required<TagInfo[]>();
readonly tagSelected = output<string>();
readonly searchTerm = signal('');
readonly activeLetter = signal<string | null>(null);
private readonly sections = computed<TagSection[]>(() => {
const rawTerm = this.searchTerm().trim();
const normalizedTerm = this.normalize(rawTerm);
const allTags = (this.tags() ?? []).slice().sort((a, b) => this.collator.compare(a.name, b.name));
const filtered = rawTerm
? allTags.filter((tag) => this.normalize(tag.name).includes(normalizedTerm))
: allTags;
const grouped = new Map<string, TagInfo[]>();
for (const tag of filtered) {
const letter = this.extractLetter(tag.name);
if (!grouped.has(letter)) {
grouped.set(letter, []);
}
grouped.get(letter)!.push(tag);
}
const sortedSections = Array.from(grouped.entries())
.sort((a, b) => this.sortLetters(a[0], b[0]))
.map(([letter, tags]) => ({
letter,
tags: tags.slice().sort((a, b) => this.collator.compare(a.name, b.name)),
}));
return sortedSections;
});
readonly availableLetters = computed(() => this.sections().map((section) => section.letter));
readonly displayedSections = computed(() => {
const active = this.activeLetter();
if (!active) {
return this.sections();
}
return this.sections().filter((section) => section.letter === active);
});
constructor() {
effect(() => {
const letters = this.availableLetters();
const active = this.activeLetter();
if (active && !letters.includes(active)) {
this.resetLetterState();
}
});
effect(() => {
if (!this.searchTerm().trim()) {
this.resetLetterState();
}
});
}
private normalize(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
private extractLetter(name: string): string {
const normalized = this.normalize(name).toUpperCase();
const char = normalized.charAt(0);
if (!char) {
return '#';
}
if (/[A-Z]/.test(char)) {
return char;
}
if (/[0-9]/.test(char)) {
return '#';
}
return char;
}
private sortLetters(a: string, b: string): number {
const rank = (value: string): number => {
if (value === '#') {
return 0;
}
if (/^[A-Z]$/.test(value)) {
return value.charCodeAt(0) - 64;
}
return 100 + value.charCodeAt(0);
};
const diff = rank(a) - rank(b);
return diff !== 0 ? diff : this.collator.compare(a, b);
}
onSearchChange(raw: string): void {
this.searchTerm.set(raw);
if (raw.trim()) {
this.activeLetter.set(null);
}
}
clearSearch(): void {
this.searchTerm.set('');
this.resetLetterState();
}
onLetterClick(letter: string): void {
const active = this.activeLetter();
if (active === letter) {
this.resetLetterState();
return;
}
this.activeLetter.set(letter);
}
private resetLetterState(): void {
this.activeLetter.set(null);
}
}