first commit
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
<!-- ObsiViewer - Application optimisée pour mobile et desktop -->
|
||||
<main class="relative flex min-h-screen flex-col bg-obs-l-bg-main text-obs-l-text-main dark:bg-obs-d-bg-main dark:text-obs-d-text-main lg:flex-row lg:h-screen lg:overflow-hidden">
|
||||
<!-- Navigation latérale desktop -->
|
||||
<nav class="hidden w-14 flex-col items-center gap-4 border-r border-obs-l-border bg-obs-l-bg-main py-4 dark:border-obs-d-border dark:bg-obs-d-bg-main lg:flex">
|
||||
<button
|
||||
(click)="setView('files')"
|
||||
class="rounded-lg p-2 transition hover:bg-obs-l-bg-secondary dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.bg-obs-l-bg-secondary]="activeView() === 'files'"
|
||||
[class.dark:bg-obs-d-bg-secondary]="activeView() === 'files'"
|
||||
aria-label="Afficher les fichiers"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 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" /></svg>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('search')"
|
||||
class="rounded-lg p-2 transition hover:bg-obs-l-bg-secondary dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.bg-obs-l-bg-secondary]="activeView() === 'search'"
|
||||
[class.dark:bg-obs-d-bg-secondary]="activeView() === 'search'"
|
||||
aria-label="Ouvrir la recherche"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('tags')"
|
||||
class="rounded-lg p-2 transition hover:bg-obs-l-bg-secondary dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.bg-obs-l-bg-secondary]="activeView() === 'tags'"
|
||||
[class.dark:bg-obs-d-bg-secondary]="activeView() === 'tags'"
|
||||
aria-label="Afficher les tags"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 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="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-5 5a2 2 0 01-2.828 0l-7-7A2 2 0 013 8V3z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('graph')"
|
||||
class="rounded-lg p-2 transition hover:bg-obs-l-bg-secondary dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.bg-obs-l-bg-secondary]="activeView() === 'graph'"
|
||||
[class.dark:bg-obs-d-bg-secondary]="activeView() === 'graph'"
|
||||
aria-label="Afficher la vue graphe"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 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="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('calendar')"
|
||||
class="rounded-lg p-2 transition hover:bg-obs-l-bg-secondary dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.bg-obs-l-bg-secondary]="activeView() === 'calendar'"
|
||||
[class.dark:bg-obs-d-bg-secondary]="activeView() === 'calendar'"
|
||||
aria-label="Afficher le calendrier"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 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="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2h-1.5a1.5 1.5 0 01-3 0h-5a1.5 1.5 0 01-3 0H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<nav class="sticky bottom-0 z-30 flex w-full items-center justify-around gap-2 border-t border-obs-l-border bg-obs-l-bg-main/95 px-2 py-2 backdrop-blur-xs dark:border-obs-d-border dark:bg-obs-d-bg-main/95 lg:hidden">
|
||||
<button
|
||||
(click)="setView('files'); toggleSidebarTo(true)"
|
||||
class="flex flex-1 flex-col items-center gap-1 rounded-lg px-3 py-2 text-xs font-semibold text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.text-obs-l-text-main]="activeView() === 'files'"
|
||||
[class.dark:text-obs-d-text-main]="activeView() === 'files'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" 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" /></svg>
|
||||
Fichiers
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('search'); toggleSidebarTo(true)"
|
||||
class="flex flex-1 flex-col items-center gap-1 rounded-lg px-3 py-2 text-xs font-semibold text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.text-obs-l-text-main]="activeView() === 'search'"
|
||||
[class.dark:text-obs-d-text-main]="activeView() === 'search'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||
Recherche
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('tags'); toggleSidebarTo(true)"
|
||||
class="flex flex-1 flex-col items-center gap-1 rounded-lg px-3 py-2 text-xs font-semibold text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.text-obs-l-text-main]="activeView() === 'tags'"
|
||||
[class.dark:text-obs-d-text-main]="activeView() === 'tags'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-5 5a2 2 0 01-2.828 0l-7-7A2 2 0 013 8V3z" /></svg>
|
||||
Tags
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('calendar'); toggleSidebarTo(true)"
|
||||
class="flex flex-1 flex-col items-center gap-1 rounded-lg px-3 py-2 text-xs font-semibold text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.text-obs-l-text-main]="activeView() === 'calendar'"
|
||||
[class.dark:text-obs-d-text-main]="activeView() === 'calendar'"
|
||||
[attr.aria-pressed]="activeView() === 'calendar'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2h-1.5a1.5 1.5 0 01-3 0h-5a1.5 1.5 0 01-3 0H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
Agenda
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@if (isDesktop() || isSidebarOpen()) {
|
||||
<aside
|
||||
class="fixed inset-y-0 left-0 z-40 flex h-screen w-[min(320px,85vw)] -translate-x-full transform flex-col border-r border-obs-l-border bg-obs-l-bg-secondary shadow-xl transition-transform duration-200 ease-in-out dark:border-obs-d-border dark:bg-obs-d-bg-secondary lg:static lg:h-auto lg:w-auto lg:translate-x-0 lg:shadow-none"
|
||||
[class.translate-x-0]="isSidebarOpen() || isDesktop()"
|
||||
[class.pointer-events-none]="!isSidebarOpen() && !isDesktop()"
|
||||
[style.width.px]="isDesktop() ? (isSidebarOpen() ? leftSidebarWidth() : 0) : null"
|
||||
[style.minWidth.px]="isDesktop() ? (isSidebarOpen() ? leftSidebarWidth() : 0) : null"
|
||||
[style.maxWidth.px]="isDesktop() ? (isSidebarOpen() ? leftSidebarWidth() : 0) : null"
|
||||
role="navigation"
|
||||
aria-label="Arborescence de la voûte"
|
||||
>
|
||||
<div class="flex h-full flex-col overflow-hidden"
|
||||
[style.width.px]="isDesktop() ? (isSidebarOpen() ? leftSidebarWidth() : 0) : null"
|
||||
>
|
||||
<div class="space-y-4 border-b border-obs-l-border bg-obs-l-bg-secondary/60 px-4 py-4 dark:border-obs-d-border dark:bg-obs-d-bg-secondary/60">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-2xl border border-obs-l-border/60 bg-obs-l-bg-main/70 text-obs-l-text-muted shadow-sm dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/60 dark:text-obs-d-text-muted">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM5.5 21a6.5 6.5 0 0113 0" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm font-semibold tracking-wide text-obs-l-text-main dark:text-obs-d-text-main">{{ vaultName() }}</span>
|
||||
<div class="mt-1 flex items-center gap-2 text-xs uppercase text-obs-l-text-muted dark:text-obs-d-text-muted">
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-obs-l-bg-main/70 px-2 py-0.5 text-[0.65rem] font-semibold tracking-widest text-obs-l-text-main/80 dark:bg-obs-d-bg-main/60 dark:text-obs-d-text-main/80">{{ activeView() | titlecase }}</span>
|
||||
<span class="hidden text-[0.65rem] tracking-widest text-obs-l-text-muted/80 dark:text-obs-d-text-muted/70 sm:inline">Vue active</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!isDesktop()) {
|
||||
<button
|
||||
class="rounded-xl border border-transparent bg-obs-l-bg-main/70 p-2 text-obs-l-text-muted transition hover:border-obs-l-border/50 hover:bg-obs-l-bg-main/90 dark:bg-obs-d-bg-main/60 dark:text-obs-d-text-muted dark:hover:border-obs-d-border/60 dark:hover:bg-obs-d-bg-main/70"
|
||||
(click)="closeSidebar()"
|
||||
aria-label="Fermer le panneau latéral"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-obs-l-border/60 bg-obs-l-bg-main/75 px-3 py-3 shadow-inner dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/60">
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<button
|
||||
(click)="setView('files')"
|
||||
class="group flex flex-col items-center gap-1 rounded-xl border border-transparent px-2.5 py-2 text-xs font-medium text-obs-l-text-muted transition duration-150 hover:border-obs-l-border/50 hover:bg-obs-l-bg-main/60 hover:text-obs-l-text-main dark:text-obs-d-text-muted dark:hover:border-obs-d-border/50 dark:hover:bg-obs-d-bg-main/60 dark:hover:text-obs-d-text-main"
|
||||
[ngClass]="{ 'bg-obs-l-bg-main/80 text-obs-l-text-main shadow-sm dark:bg-obs-d-bg-main/70 dark:text-obs-d-text-main': activeView() === 'files' }"
|
||||
aria-label="Afficher les fichiers"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" 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" /></svg>
|
||||
<span>Fichiers</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('search')"
|
||||
class="group flex flex-col items-center gap-1 rounded-xl border border-transparent px-2.5 py-2 text-xs font-medium text-obs-l-text-muted transition duration-150 hover:border-obs-l-border/50 hover:bg-obs-l-bg-main/60 hover:text-obs-l-text-main dark:text-obs-d-text-muted dark:hover:border-obs-d-border/50 dark:hover:bg-obs-d-bg-main/60 dark:hover:text-obs-d-text-main"
|
||||
[ngClass]="{ 'bg-obs-l-bg-main/80 text-obs-l-text-main shadow-sm dark:bg-obs-d-bg-main/70 dark:text-obs-d-text-main': activeView() === 'search' }"
|
||||
aria-label="Ouvrir la recherche"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||
<span>Recherche</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('tags')"
|
||||
class="group flex flex-col items-center gap-1 rounded-xl border border-transparent px-2.5 py-2 text-xs font-medium text-obs-l-text-muted transition duration-150 hover:border-obs-l-border/50 hover:bg-obs-l-bg-main/60 hover:text-obs-l-text-main dark:text-obs-d-text-muted dark:hover:border-obs-d-border/50 dark:hover:bg-obs-d-bg-main/60 dark:hover:text-obs-d-text-main"
|
||||
[ngClass]="{ 'bg-obs-l-bg-main/80 text-obs-l-text-main shadow-sm dark:bg-obs-d-bg-main/70 dark:text-obs-d-text-main': activeView() === 'tags' }"
|
||||
aria-label="Afficher les tags"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-5 5a2 2 0 01-2.828 0l-7-7A2 2 0 013 8V3z" /></svg>
|
||||
<span>Tags</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('calendar')"
|
||||
class="group flex flex-col items-center gap-1 rounded-xl border border-transparent px-2.5 py-2 text-xs font-medium text-obs-l-text-muted transition duration-150 hover:border-obs-l-border/50 hover:bg-obs-l-bg-main/60 hover:text-obs-l-text-main dark:text-obs-d-text-muted dark:hover:border-obs-d-border/50 dark:hover:bg-obs-d-bg-main/60 dark:hover:text-obs-d-text-main"
|
||||
[ngClass]="{ 'bg-obs-l-bg-main/80 text-obs-l-text-main shadow-sm dark:bg-obs-d-bg-main/70 dark:text-obs-d-text-main': activeView() === 'calendar' }"
|
||||
[attr.aria-pressed]="activeView() === 'calendar'"
|
||||
aria-label="Afficher l'agenda"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2h-1.5a1.5 1.5 0 01-3 0h-5a1.5 1.5 0 01-3 0H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
<span>Agenda</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
@switch (activeView()) {
|
||||
@case ('files') {
|
||||
<app-file-explorer
|
||||
[nodes]="filteredFileTree()"
|
||||
[selectedNoteId]="selectedNoteId()"
|
||||
(fileSelected)="selectNote($event)"
|
||||
></app-file-explorer>
|
||||
}
|
||||
@case ('tags') {
|
||||
<app-tags-view [tags]="filteredTags()" (tagSelected)="handleTagClick($event)"></app-tags-view>
|
||||
}
|
||||
@case ('graph') {
|
||||
<div class="h-full p-2"><app-graph-view [graphData]="graphData()" (nodeSelected)="selectNote($event)"></app-graph-view></div>
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
@case ('search') {
|
||||
<div class="space-y-2 p-3">
|
||||
@if (activeTagFilter(); as tagFilter) {
|
||||
<div class="flex items-center justify-between rounded-lg border border-obs-l-border/60 bg-obs-l-bg-main/70 px-3 py-2 dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/70">
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-obs-l-text-main dark:text-obs-d-text-main">
|
||||
<span>🔖</span>
|
||||
<span class="truncate">#{{ tagFilter }}</span>
|
||||
</div>
|
||||
<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" (click)="clearTagFilter()">Effacer</button>
|
||||
</div>
|
||||
}
|
||||
<h3 class="px-2 py-1 text-sm font-semibold text-obs-l-text-muted dark:text-obs-d-text-muted">{{ searchResults().length }} résultats</h3>
|
||||
<ul class="space-y-1">
|
||||
@for (note of searchResults(); track note.id) {
|
||||
<li
|
||||
(click)="selectNote(note.id)"
|
||||
class="cursor-pointer rounded px-2 py-2 transition hover:bg-obs-l-bg-main dark:hover:bg-obs-d-bg-main"
|
||||
[class.bg-obs-l-bg-main]="selectedNoteId() === note.id"
|
||||
[class.dark:bg-obs-d-bg-main]="selectedNoteId() === note.id"
|
||||
>
|
||||
<div class="truncate font-semibold">{{ note.title }}</div>
|
||||
<div class="truncate text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">
|
||||
{{ note.content.substring(0, 100) }}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@if (calendarSelectionLabel() || calendarSearchState() !== 'idle' || calendarResults().length > 0 || calendarSearchError()) {
|
||||
<div class="mt-4 space-y-2 border-t border-obs-l-border/60 pt-3 dark:border-obs-d-border/60">
|
||||
<div class="flex items-center justify-between px-2">
|
||||
<h3 class="text-sm font-semibold text-obs-l-text-muted dark:text-obs-d-text-muted">Résultats du calendrier</h3>
|
||||
@if (calendarSelectionLabel(); as selectionLabel) {
|
||||
<span class="text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">{{ selectionLabel }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (calendarSearchState() === 'loading') {
|
||||
<div class="px-2 py-2 text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">Recherche en cours...</div>
|
||||
} @else if (calendarSearchError(); as calError) {
|
||||
<div class="px-2 py-2 text-xs text-red-500 dark:text-red-400">{{ calError }}</div>
|
||||
} @else if (calendarResults().length === 0) {
|
||||
<div class="px-2 py-2 text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">Sélectionnez un jour ou une plage dans le calendrier.</div>
|
||||
} @else {
|
||||
<ul class="mt-2 space-y-1">
|
||||
@for (file of calendarResults(); track file.id) {
|
||||
<li>
|
||||
<button
|
||||
(click)="selectNote(file.id)"
|
||||
class="w-full rounded-lg bg-obs-l-bg-main/80 px-2 py-2 text-left transition hover:bg-obs-l-bg-main dark:bg-obs-d-bg-main/60 dark:hover:bg-obs-d-bg-main"
|
||||
>
|
||||
<div class="truncate text-sm font-semibold text-obs-l-text-main dark:text-obs-d-text-main">{{ file.title }}</div>
|
||||
<div class="flex flex-wrap gap-2 text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">
|
||||
<span>Créé : {{ file.createdAt | date:'mediumDate' }}</span>
|
||||
<span>Modifié : {{ file.updatedAt | date:'mediumDate' }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@case ('calendar') {
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="flex items-center justify-between border-b border-obs-l-border/60 bg-obs-l-bg-main/70 px-4 py-3 dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/60">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-obs-l-text-main dark:text-obs-d-text-main">Vue agenda</h3>
|
||||
@if (calendarSelectionLabel(); as selectionLabel) {
|
||||
<p class="mt-1 text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">{{ selectionLabel }}</p>
|
||||
} @else {
|
||||
<p class="mt-1 text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">Sélectionnez un jour ou une plage pour voir les notes correspondantes.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex-1 px-3 py-4">
|
||||
<div
|
||||
class="rounded-2xl border border-obs-l-border/50 bg-obs-l-bg-main/80 p-3 shadow-sm transition duration-200 dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/70"
|
||||
[class.opacity-0]="calendarOverlayVisible()"
|
||||
[class.pointer-events-none]="calendarOverlayVisible()"
|
||||
>
|
||||
<app-markdown-calendar
|
||||
(fileSelected)="selectNote($event)"
|
||||
(searchResultsChange)="onCalendarResultsChange($event)"
|
||||
(searchStateChange)="onCalendarSearchStateChange($event)"
|
||||
(searchErrorChange)="onCalendarSearchErrorChange($event)"
|
||||
(selectionSummaryChange)="onCalendarSelectionSummaryChange($event)"
|
||||
(requestSearchPanel)="onCalendarRequestSearchPanel()"
|
||||
></app-markdown-calendar>
|
||||
</div>
|
||||
@if (calendarOverlayVisible()) {
|
||||
<div class="absolute inset-0 flex flex-col overflow-hidden rounded-2xl border border-obs-l-border/60 bg-obs-l-bg-main/95 shadow-2xl dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/95">
|
||||
<div class="flex items-center justify-between border-b border-obs-l-border/60 px-4 py-3 dark:border-obs-d-border/60">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h4 class="text-sm font-semibold text-obs-l-text-main dark:text-obs-d-text-main">Notes correspondantes</h4>
|
||||
@if (calendarSelectionLabel(); as selectionLabel) {
|
||||
<span class="text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">{{ selectionLabel }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
@if (calendarResults().length > 0) {
|
||||
<span class="text-xs font-medium text-obs-l-text-muted dark:text-obs-d-text-muted">{{ calendarResults().length }} {{ calendarResults().length > 1 ? 'notes' : 'note' }}</span>
|
||||
}
|
||||
<button
|
||||
(click)="clearCalendarResults()"
|
||||
class="rounded-lg border border-transparent p-1.5 text-obs-l-text-muted transition hover:border-obs-l-border/60 hover:bg-obs-l-bg-main/80 hover:text-obs-l-text-main dark:text-obs-d-text-muted dark:hover:border-obs-d-border/60 dark:hover:bg-obs-d-bg-main/70 dark:hover:text-obs-d-text-main"
|
||||
aria-label="Fermer les résultats du calendrier"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 space-y-2 overflow-y-auto px-4 py-4">
|
||||
@if (calendarSearchState() === 'loading') {
|
||||
<div class="rounded-lg bg-obs-l-bg-main/70 px-3 py-3 text-sm text-obs-l-text-muted dark:bg-obs-d-bg-main/70 dark:text-obs-d-text-muted">Recherche en cours...</div>
|
||||
} @else if (calendarSearchError(); as calError) {
|
||||
<div class="rounded-lg border border-red-500/40 bg-red-500/10 px-3 py-3 text-sm text-red-500 dark:border-red-500/30 dark:bg-red-500/15 dark:text-red-400">{{ calError }}</div>
|
||||
} @else if (calendarResults().length === 0) {
|
||||
<div class="rounded-lg bg-obs-l-bg-main/70 px-3 py-3 text-sm text-obs-l-text-muted dark:bg-obs-d-bg-main/70 dark:text-obs-d-text-muted">Sélectionnez une période pour afficher les notes.</div>
|
||||
} @else {
|
||||
<ul class="space-y-2">
|
||||
@for (file of calendarResults(); track file.id) {
|
||||
<li>
|
||||
<button
|
||||
(click)="selectNote(file.id)"
|
||||
class="w-full rounded-xl border border-transparent bg-obs-l-bg-main/85 px-3 py-3 text-left transition hover:border-obs-l-border/60 hover:bg-obs-l-bg-main dark:bg-obs-d-bg-main/70 dark:hover:border-obs-d-border/60 dark:hover:bg-obs-d-bg-main"
|
||||
>
|
||||
<div class="truncate text-sm font-semibold text-obs-l-text-main dark:text-obs-d-text-main">{{ file.title }}</div>
|
||||
<div class="mt-1 flex flex-wrap gap-2 text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">
|
||||
<span>Créé : {{ file.createdAt | date:'mediumDate' }}</span>
|
||||
<span>Modifié : {{ file.updatedAt | date:'mediumDate' }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="border-t border-obs-l-border/60 bg-obs-l-bg-main/60 px-4 py-4 dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/60">
|
||||
@if (calendarOverlayVisible()) {
|
||||
<p class="text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">Fermez le panneau pour revenir au calendrier ou sélectionner une nouvelle période.</p>
|
||||
} @else {
|
||||
<p class="text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">Choisissez une date pour afficher les notes associées.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
@if (!isDesktop() && isSidebarOpen()) {
|
||||
<div
|
||||
class="fixed inset-0 z-30 bg-black/40 transition-opacity duration-200"
|
||||
(click)="closeSidebar()"
|
||||
></div>
|
||||
}
|
||||
|
||||
<div class="hidden lg:flex">
|
||||
<div
|
||||
class="resize-handle h-full"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Redimensionner la barre latérale gauche"
|
||||
(pointerdown)="startLeftResize($event)"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<section class="flex min-w-0 flex-col bg-obs-l-bg-main pb-16 dark:bg-obs-d-bg-main lg:flex-1 lg:min-h-0 lg:overflow-hidden lg:pb-0">
|
||||
<header class="flex flex-col gap-4 border-b border-obs-l-border/60 bg-obs-l-bg-main/95 px-4 py-3 backdrop-blur-xs dark:border-obs-d-border dark:bg-obs-d-bg-main/95 lg:px-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<button
|
||||
class="rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary lg:hidden"
|
||||
(click)="toggleSidebar()"
|
||||
[attr.aria-expanded]="isSidebarOpen()"
|
||||
aria-label="Basculer le menu"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /></svg>
|
||||
</button>
|
||||
<button
|
||||
class="hidden rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary lg:inline-flex"
|
||||
(click)="toggleSidebar()"
|
||||
[attr.aria-expanded]="isSidebarOpen()"
|
||||
aria-label="Afficher ou masquer la barre latérale gauche"
|
||||
>
|
||||
@if (isSidebarOpen()) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/><path d="m16 15-3-3 3-3"/></svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/><path d="m14 9 3 3-3 3"/></svg>
|
||||
}
|
||||
</button>
|
||||
<div class="min-w-0 flex flex-col gap-1">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="inline-flex items-center rounded-lg border border-obs-l-border bg-obs-l-bg-secondary/60 px-2.5 py-1 text-xs font-semibold uppercase tracking-wide text-obs-l-text-muted dark:border-obs-d-border dark:bg-obs-d-bg-secondary/60 dark:text-obs-d-text-muted">{{ vaultName() }}</span>
|
||||
<h1 class="text-lg font-semibold leading-tight text-obs-l-text-main dark:text-obs-d-text-main">ObsiWatcher</h1>
|
||||
</div>
|
||||
@if (selectedNoteBreadcrumb().length > 0) {
|
||||
<p class="truncate text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">{{ selectedNoteBreadcrumb().join(' / ') }}</p>
|
||||
} @else {
|
||||
<p class="truncate text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">Aucune note sélectionnée</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden items-center gap-2 lg:flex">
|
||||
<button
|
||||
(click)="toggleTheme()"
|
||||
class="rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
aria-label="Basculer le thème"
|
||||
>
|
||||
@if (isDarkMode()) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
(click)="toggleOutline()"
|
||||
class="rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
[attr.aria-expanded]="isOutlineOpen()"
|
||||
aria-label="Basculer la barre latérale droite"
|
||||
>
|
||||
@if (isOutlineOpen()) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/><path d="m8 9 3 3-3 3"/></svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/><path d="m10 15-3-3 3-3"/></svg>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between lg:gap-6">
|
||||
<div class="flex items-center gap-2 lg:hidden">
|
||||
<button
|
||||
(click)="toggleTheme()"
|
||||
class="rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
aria-label="Basculer le thème"
|
||||
>
|
||||
@if (isDarkMode()) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
(click)="toggleOutline()"
|
||||
class="rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
[attr.aria-expanded]="isOutlineOpen()"
|
||||
aria-label="Basculer le calendrier"
|
||||
>
|
||||
@if (isOutlineOpen()) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/><path d="m8 9 3 3-3 3"/></svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/><path d="m10 15-3-3 3-3"/></svg>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative w-full max-w-2xl lg:max-w-3xl">
|
||||
<svg class="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 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-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher dans la voûte..."
|
||||
[(ngModel)]="sidebarSearchTerm"
|
||||
(ngModelChange)="activeView.set('search')"
|
||||
class="w-full rounded-full border border-obs-l-border bg-obs-l-bg-secondary/60 pl-11 pr-4 py-2.5 text-sm text-obs-l-text-main placeholder:text-obs-l-text-muted shadow-sm focus:outline-none focus:ring-2 focus:ring-obs-l-accent dark:border-obs-d-border dark:bg-obs-d-bg-secondary/60 dark:text-obs-d-text-main dark:placeholder:text-obs-d-text-muted dark:focus:ring-obs-d-accent"
|
||||
aria-label="Rechercher dans la voûte"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="min-h-0 px-4 py-6 note-content-area lg:flex-1 lg:overflow-y-auto lg:px-8">
|
||||
@if (selectedNote(); as note) {
|
||||
<app-note-viewer
|
||||
[note]="note"
|
||||
[noteHtmlContent]="renderedNoteContent()"
|
||||
(noteLinkClicked)="selectNote($event)"
|
||||
(tagClicked)="handleTagClick($event)"
|
||||
></app-note-viewer>
|
||||
} @else {
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<p class="text-obs-l-text-muted dark:text-obs-d-text-muted">Sélectionnez une note pour commencer</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="hidden lg:flex">
|
||||
<div
|
||||
class="resize-handle h-full"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Redimensionner la barre latérale droite"
|
||||
(pointerdown)="startRightResize($event)"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
@if (!isDesktop() && isOutlineOpen()) {
|
||||
<div
|
||||
class="fixed inset-0 z-30 bg-black/40 transition-opacity duration-200"
|
||||
(click)="closeOutlinePanel()"
|
||||
></div>
|
||||
}
|
||||
|
||||
@if (isDesktop() || isOutlineOpen()) {
|
||||
<aside
|
||||
class="fixed inset-x-0 bottom-0 z-40 flex max-h-[80vh] flex-col overflow-hidden border-t border-obs-l-border bg-obs-l-bg-secondary shadow-2xl transition-all duration-200 ease-out dark:border-obs-d-border dark:bg-obs-d-bg-secondary lg:static lg:max-h-none lg:border-l lg:shadow-none"
|
||||
[ngClass]="{
|
||||
'translate-y-0 opacity-100 pointer-events-auto': isOutlineOpen() || isDesktop(),
|
||||
'translate-y-full opacity-0 pointer-events-none': !isOutlineOpen() && !isDesktop()
|
||||
}"
|
||||
[style.width.px]="isDesktop() ? (isOutlineOpen() ? rightSidebarWidth() : 0) : null"
|
||||
[style.minWidth.px]="isDesktop() ? (isOutlineOpen() ? rightSidebarWidth() : 0) : null"
|
||||
[style.maxWidth.px]="isDesktop() ? (isOutlineOpen() ? rightSidebarWidth() : 0) : null"
|
||||
role="complementary"
|
||||
aria-label="Table des matières et calendrier"
|
||||
>
|
||||
<div class="flex flex-col lg:h-full">
|
||||
@if (!isDesktop()) {
|
||||
<div class="px-4 pt-3">
|
||||
<div class="mx-auto mb-3 h-1.5 w-12 rounded-full bg-obs-l-border dark:bg-obs-d-border"></div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold text-obs-l-text-main dark:text-obs-d-text-main">Navigation</h2>
|
||||
<button
|
||||
class="rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-main/70 dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-main/60"
|
||||
(click)="closeOutlinePanel()"
|
||||
aria-label="Fermer le calendrier"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs uppercase tracking-wide text-obs-l-text-muted dark:text-obs-d-text-muted">Table des matières & calendrier</p>
|
||||
</div>
|
||||
}
|
||||
<div class="hidden border-b border-obs-l-border px-4 py-3 text-sm font-semibold uppercase tracking-wide text-obs-l-text-muted dark:border-obs-d-border dark:text-obs-d-text-muted lg:block">
|
||||
Outline
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto px-4 py-4">
|
||||
@if (tableOfContents().length > 0) {
|
||||
<ul class="space-y-2">
|
||||
@for (entry of tableOfContents(); track entry.id) {
|
||||
<li [style.padding-left.rem]="(entry.level - 1) * 0.75" class="flex items-start gap-2 text-sm text-obs-l-text-muted dark:text-obs-d-text-muted">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="mt-1 h-3 w-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" /></svg>
|
||||
<a (click)="scrollToHeading(entry.id)" class="cursor-pointer leading-tight transition hover:text-obs-l-text-main dark:hover:text-obs-d-text-main">
|
||||
{{ entry.text }}
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p class="text-sm italic text-obs-l-text-muted dark:text-obs-d-text-muted">Aucun titre dans cette note.</p>
|
||||
}
|
||||
</div>
|
||||
<div class="border-t border-obs-l-border bg-obs-l-bg-secondary/60 p-4 dark:border-obs-d-border dark:bg-obs-d-bg-secondary/60">
|
||||
<app-markdown-calendar
|
||||
(fileSelected)="selectNote($event)"
|
||||
(searchResultsChange)="onCalendarResultsChange($event)"
|
||||
(searchStateChange)="onCalendarSearchStateChange($event)"
|
||||
(searchErrorChange)="onCalendarSearchErrorChange($event)"
|
||||
(selectionSummaryChange)="onCalendarSelectionSummaryChange($event)"
|
||||
(requestSearchPanel)="onCalendarRequestSearchPanel()"
|
||||
></app-markdown-calendar>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
}
|
||||
</main>
|
||||
@@ -0,0 +1,868 @@
|
||||
/* Mobile-first: do NOT trap scroll inside the note container */
|
||||
:host ::ng-deep .note-content-area {
|
||||
overflow: visible;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
:host ::ng-deep .note-content-area {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .note-content-area::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.4);
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .note-content-area::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(148, 163, 184, 0.65);
|
||||
}
|
||||
|
||||
:not(.dark) ::ng-deep .note-content-area::-webkit-scrollbar-thumb {
|
||||
background: rgba(100, 116, 139, 0.28);
|
||||
}
|
||||
|
||||
:not(.dark) ::ng-deep .note-content-area::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(100, 116, 139, 0.45);
|
||||
}
|
||||
|
||||
:host ::ng-deep .md-tag-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
:host ::ng-deep .md-tag-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.1rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
color: #0f172a;
|
||||
background: rgba(15, 118, 110, 0.12);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
:host ::ng-deep .md-tag-badge:hover,
|
||||
:host ::ng-deep .md-tag-badge:focus-visible {
|
||||
transform: translateY(-1px);
|
||||
background: rgba(15, 118, 110, 0.18);
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .md-tag-badge {
|
||||
background: rgba(45, 212, 191, 0.22);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .md-tag-badge:hover,
|
||||
:host-context(.dark) ::ng-deep .md-tag-badge:focus-visible {
|
||||
background: rgba(45, 212, 191, 0.32);
|
||||
}
|
||||
|
||||
:host ::ng-deep .md-tag-badge[data-origin="frontmatter"] {
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
:host ::ng-deep .md-tag-badge::before {
|
||||
content: '🔖';
|
||||
margin-right: 0.15rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
:host ::ng-deep .md-tag-badge span {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
:host ::ng-deep .md-tag-palette-0 { background: rgba(190, 242, 100, 0.35); color: #3f6212; }
|
||||
:host ::ng-deep .md-tag-palette-1 { background: rgba(129, 230, 217, 0.35); color: #0f766e; }
|
||||
:host ::ng-deep .md-tag-palette-2 { background: rgba(196, 181, 253, 0.35); color: #6b21a8; }
|
||||
:host ::ng-deep .md-tag-palette-3 { background: rgba(248, 196, 113, 0.35); color: #b45309; }
|
||||
:host ::ng-deep .md-tag-palette-4 { background: rgba(248, 113, 113, 0.35); color: #b91c1c; }
|
||||
:host ::ng-deep .md-tag-palette-5 { background: rgba(129, 140, 248, 0.35); color: #4338ca; }
|
||||
:host ::ng-deep .md-tag-palette-6 { background: rgba(233, 213, 255, 0.35); color: #7e22ce; }
|
||||
:host ::ng-deep .md-tag-palette-7 { background: rgba(209, 250, 229, 0.35); color: #047857; }
|
||||
:host ::ng-deep .md-tag-palette-8 { background: rgba(165, 180, 252, 0.35); color: #1d4ed8; }
|
||||
:host ::ng-deep .md-tag-palette-9 { background: rgba(253, 224, 71, 0.35); color: #92400e; }
|
||||
:host ::ng-deep .md-tag-palette-10 { background: rgba(244, 114, 182, 0.35); color: #be185d; }
|
||||
:host ::ng-deep .md-tag-palette-11 { background: rgba(148, 163, 184, 0.35); color: #1f2937; }
|
||||
|
||||
:host-context(.dark) ::ng-deep .md-tag-palette-0 { background: rgba(132, 204, 22, 0.35); color: #d9f99d; }
|
||||
:host-context(.dark) ::ng-deep .md-tag-palette-1 { background: rgba(45, 212, 191, 0.3); color: #d1fae5; }
|
||||
:host-context(.dark) ::ng-deep .md-tag-palette-2 { background: rgba(168, 85, 247, 0.28); color: #ede9fe; }
|
||||
:host-context(.dark) ::ng-deep .md-tag-palette-3 { background: rgba(245, 158, 11, 0.28); color: #fde68a; }
|
||||
:host-context(.dark) ::ng-deep .md-tag-palette-4 { background: rgba(248, 113, 113, 0.3); color: #fee2e2; }
|
||||
:host-context(.dark) ::ng-deep .md-tag-palette-5 { background: rgba(99, 102, 241, 0.3); color: #e0e7ff; }
|
||||
:host-context(.dark) ::ng-deep .md-tag-palette-6 { background: rgba(217, 70, 239, 0.28); color: #f5d0fe; }
|
||||
:host-context(.dark) ::ng-deep .md-tag-palette-7 { background: rgba(34, 197, 94, 0.3); color: #bbf7d0; }
|
||||
:host-context(.dark) ::ng-deep .md-tag-palette-8 { background: rgba(59, 130, 246, 0.28); color: #bfdbfe; }
|
||||
:host-context(.dark) ::ng-deep .md-tag-palette-9 { background: rgba(250, 204, 21, 0.28); color: #fef08a; }
|
||||
:host-context(.dark) ::ng-deep .md-tag-palette-10 { background: rgba(236, 72, 153, 0.3); color: #fbcfe8; }
|
||||
:host-context(.dark) ::ng-deep .md-tag-palette-11 { background: rgba(107, 114, 128, 0.3); color: #cbd5f5; }
|
||||
|
||||
:host ::ng-deep .metadata-panel__item .md-tag-badge {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
:host ::ng-deep .metadata-panel__actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
:host ::ng-deep .metadata-panel__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem 0.95rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid rgba(15, 118, 110, 0.35);
|
||||
background: rgba(15, 118, 110, 0.08);
|
||||
color: #0f766e;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
:host ::ng-deep .metadata-panel__toggle:hover,
|
||||
:host ::ng-deep .metadata-panel__toggle:focus-visible {
|
||||
background: rgba(15, 118, 110, 0.18);
|
||||
border-color: rgba(15, 118, 110, 0.55);
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .metadata-panel__toggle {
|
||||
border-color: rgba(45, 212, 191, 0.35);
|
||||
background: rgba(45, 212, 191, 0.18);
|
||||
color: #5eead4;
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .metadata-panel__toggle:hover,
|
||||
:host-context(.dark) ::ng-deep .metadata-panel__toggle:focus-visible {
|
||||
background: rgba(45, 212, 191, 0.28);
|
||||
border-color: rgba(45, 212, 191, 0.55);
|
||||
}
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(circle at top left, rgba(99, 102, 241, 0.08), transparent 45%),
|
||||
radial-gradient(circle at bottom right, rgba(16, 185, 129, 0.08), transparent 40%);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
:host {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar for webkit browsers */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: #3c3d3f;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: #5c6166;
|
||||
}
|
||||
|
||||
/* Light mode scrollbar */
|
||||
:not(.dark) ::-webkit-scrollbar-thumb {
|
||||
background: #d8dbe0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
:not(.dark) ::-webkit-scrollbar-thumb:hover {
|
||||
background: #b8bcc2;
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
width: 8px;
|
||||
min-width: 8px;
|
||||
background: linear-gradient(to bottom, transparent, rgba(99, 102, 241, 0.45), transparent);
|
||||
cursor: col-resize;
|
||||
transition: background 0.2s ease, width 0.2s ease, opacity 0.2s ease;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.resize-handle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(99, 102, 241, 0.6);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.resize-handle:hover,
|
||||
.resize-handle:focus-visible,
|
||||
.resize-handle:active {
|
||||
width: 10px;
|
||||
min-width: 10px;
|
||||
background: linear-gradient(to bottom, transparent, rgba(99, 102, 241, 0.6), transparent);
|
||||
}
|
||||
|
||||
.resize-handle:hover::after,
|
||||
.resize-handle:focus-visible::after,
|
||||
.resize-handle:active::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.resize-handle.pointer-events-none {
|
||||
cursor: default;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:host ::ng-deep .callout {
|
||||
margin: 1.5rem 0;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.4);
|
||||
border-left-width: 0.35rem;
|
||||
background: rgba(148, 163, 184, 0.1);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.45);
|
||||
color: #1f2937;
|
||||
transition: background 0.25s ease, border-color 0.25s ease, color 0.25s ease;
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .callout {
|
||||
background: rgba(30, 41, 59, 0.45);
|
||||
border-color: rgba(148, 163, 184, 0.35);
|
||||
box-shadow: inset 0 1px 0 rgba(148, 163, 184, 0.1);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
:host ::ng-deep .callout__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
:host ::ng-deep .callout__title::before {
|
||||
content: "";
|
||||
display: inline-flex;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1rem;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
:host ::ng-deep .callout__body p {
|
||||
margin: 0 0 0.75rem;
|
||||
line-height: 1.6;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
:host ::ng-deep .callout__body p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:host ::ng-deep .callout-note,
|
||||
:host ::ng-deep .callout[data-callout-type="note"],
|
||||
:host ::ng-deep .callout[data-callout-type="info"] {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
border-color: rgba(59, 130, 246, 0.45);
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .callout-note,
|
||||
:host-context(.dark) ::ng-deep .callout[data-callout-type="note"],
|
||||
:host-context(.dark) ::ng-deep .callout[data-callout-type="info"] {
|
||||
background: rgba(37, 99, 235, 0.28);
|
||||
border-color: rgba(147, 197, 253, 0.55);
|
||||
color: #dbeafe;
|
||||
}
|
||||
|
||||
:host ::ng-deep .callout-note .callout__title::before,
|
||||
:host ::ng-deep .callout[data-callout-type="note"] .callout__title::before,
|
||||
:host ::ng-deep .callout[data-callout-type="info"] .callout__title::before {
|
||||
content: "ℹ️";
|
||||
}
|
||||
|
||||
:host ::ng-deep .callout-tip,
|
||||
:host ::ng-deep .callout[data-callout-type="tip"],
|
||||
:host ::ng-deep .callout[data-callout-type="todo"] {
|
||||
background: rgba(16, 185, 129, 0.14);
|
||||
border-color: rgba(16, 185, 129, 0.45);
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .callout-tip,
|
||||
:host-context(.dark) ::ng-deep .callout[data-callout-type="tip"],
|
||||
:host-context(.dark) ::ng-deep .callout[data-callout-type="todo"] {
|
||||
background: rgba(6, 95, 70, 0.32);
|
||||
border-color: rgba(110, 231, 183, 0.55);
|
||||
color: #bbf7d0;
|
||||
}
|
||||
|
||||
:host ::ng-deep .callout-tip .callout__title::before,
|
||||
:host ::ng-deep .callout[data-callout-type="tip"] .callout__title::before,
|
||||
:host ::ng-deep .callout[data-callout-type="todo"] .callout__title::before {
|
||||
content: "💡";
|
||||
}
|
||||
|
||||
:host ::ng-deep .callout-warning,
|
||||
:host ::ng-deep .callout[data-callout-type="warning"],
|
||||
:host ::ng-deep .callout[data-callout-type="caution"] {
|
||||
background: rgba(251, 191, 36, 0.18);
|
||||
border-color: rgba(251, 191, 36, 0.5);
|
||||
color: #78350f;
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .callout-warning,
|
||||
:host-context(.dark) ::ng-deep .callout[data-callout-type="warning"],
|
||||
:host-context(.dark) ::ng-deep .callout[data-callout-type="caution"] {
|
||||
background: rgba(217, 119, 6, 0.35);
|
||||
border-color: rgba(253, 224, 71, 0.5);
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
:host ::ng-deep .callout-warning .callout__title::before,
|
||||
:host ::ng-deep .callout[data-callout-type="warning"] .callout__title::before,
|
||||
:host ::ng-deep .callout[data-callout-type="caution"] .callout__title::before {
|
||||
content: "⚠️";
|
||||
}
|
||||
|
||||
:host ::ng-deep .callout-danger,
|
||||
:host ::ng-deep .callout[data-callout-type="danger"],
|
||||
:host ::ng-deep .callout[data-callout-type="error"] {
|
||||
background: rgba(248, 113, 113, 0.18);
|
||||
border-color: rgba(248, 113, 113, 0.5);
|
||||
color: #7f1d1d;
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .callout-danger,
|
||||
:host-context(.dark) ::ng-deep .callout[data-callout-type="danger"],
|
||||
:host-context(.dark) ::ng-deep .callout[data-callout-type="error"] {
|
||||
background: rgba(185, 28, 28, 0.35);
|
||||
border-color: rgba(252, 165, 165, 0.55);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
:host ::ng-deep .callout-danger .callout__title::before,
|
||||
:host ::ng-deep .callout[data-callout-type="danger"] .callout__title::before,
|
||||
:host ::ng-deep .callout[data-callout-type="error"] .callout__title::before {
|
||||
content: "⛔";
|
||||
}
|
||||
|
||||
:host ::ng-deep .callout-default,
|
||||
:host ::ng-deep .callout[data-callout-type="default"] {
|
||||
background: rgba(148, 163, 184, 0.18);
|
||||
border-color: rgba(148, 163, 184, 0.45);
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .callout-default,
|
||||
:host-context(.dark) ::ng-deep .callout[data-callout-type="default"] {
|
||||
background: rgba(71, 85, 105, 0.38);
|
||||
border-color: rgba(148, 163, 184, 0.5);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
:host ::ng-deep .callout-default .callout__title::before,
|
||||
:host ::ng-deep .callout[data-callout-type="default"] .callout__title::before {
|
||||
content: "🛈";
|
||||
}
|
||||
|
||||
:host ::ng-deep .inline-code {
|
||||
font-family: "JetBrains Mono", "Fira Code", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 0.9rem;
|
||||
background: rgba(15, 118, 110, 0.08);
|
||||
border: 1px solid rgba(13, 148, 136, 0.2);
|
||||
color: #0f766e;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .inline-code {
|
||||
background: rgba(45, 212, 191, 0.12);
|
||||
border-color: rgba(34, 197, 94, 0.25);
|
||||
color: #5eead4;
|
||||
}
|
||||
|
||||
:host ::ng-deep .code-block {
|
||||
margin: 1.8rem auto;
|
||||
border-radius: 0.95rem;
|
||||
border: 1px solid rgba(71, 85, 105, 0.25);
|
||||
overflow: hidden;
|
||||
background: rgba(248, 250, 252, 0.75);
|
||||
box-shadow: 0 22px 50px -30px rgba(15, 23, 42, 0.45);
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .code-block {
|
||||
background: rgba(15, 23, 42, 0.82);
|
||||
border-color: rgba(148, 163, 184, 0.3);
|
||||
box-shadow: 0 25px 60px -35px rgba(15, 23, 42, 0.9);
|
||||
}
|
||||
|
||||
:host ::ng-deep .code-block__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: linear-gradient(90deg, rgba(15, 118, 110, 0.14), rgba(15, 118, 110, 0));
|
||||
border-bottom: 1px solid rgba(13, 148, 136, 0.22);
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .code-block__header {
|
||||
background: linear-gradient(90deg, rgba(45, 212, 191, 0.25), rgba(45, 212, 191, 0));
|
||||
border-bottom: 1px solid rgba(34, 197, 94, 0.25);
|
||||
}
|
||||
|
||||
:host ::ng-deep .code-block__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
:host ::ng-deep .code-block__controls span {
|
||||
display: inline-block;
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.6);
|
||||
}
|
||||
|
||||
:host ::ng-deep .code-block__controls span:nth-child(1) {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
:host ::ng-deep .code-block__controls span:nth-child(2) {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
:host ::ng-deep .code-block__controls span:nth-child(3) {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
:host ::ng-deep .code-block__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
:host ::ng-deep .code-block__language-badge {
|
||||
font-family: "JetBrains Mono", "Fira Code", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0.3rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 118, 110, 0.2);
|
||||
color: #0f766e;
|
||||
border: 1px solid rgba(13, 148, 136, 0.35);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .code-block__language-badge {
|
||||
background: rgba(45, 212, 191, 0.2);
|
||||
border-color: rgba(34, 197, 94, 0.4);
|
||||
color: #5eead4;
|
||||
}
|
||||
|
||||
:host ::ng-deep .code-block__language-badge:hover,
|
||||
:host ::ng-deep .code-block__language-badge:focus-visible {
|
||||
transform: translateY(-1px);
|
||||
background: rgba(15, 118, 110, 0.3);
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .code-block__language-badge:hover,
|
||||
:host-context(.dark) ::ng-deep .code-block__language-badge:focus-visible {
|
||||
background: rgba(45, 212, 191, 0.35);
|
||||
}
|
||||
|
||||
:host ::ng-deep .code-block__copy-feedback {
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: #0f766e;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .code-block__copy-feedback {
|
||||
color: #5eead4;
|
||||
}
|
||||
|
||||
:host ::ng-deep .code-block.copied {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 26px 60px -28px rgba(15, 23, 42, 0.55);
|
||||
}
|
||||
|
||||
:host ::ng-deep .code-block.copied .code-block__copy-feedback {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:host ::ng-deep .code-block__body {
|
||||
background: rgba(15, 23, 42, 0.05);
|
||||
margin: 0;
|
||||
padding: 1.2rem 1.4rem;
|
||||
overflow: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .code-block__body {
|
||||
background: rgba(15, 23, 42, 0.75);
|
||||
}
|
||||
|
||||
:host ::ng-deep .code-block__body code {
|
||||
display: block;
|
||||
font-family: "JetBrains Mono", "Fira Code", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.65;
|
||||
color: #0f172a;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .code-block__body code {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
:host ::ng-deep .code-block__body::-webkit-scrollbar {
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
:host ::ng-deep .code-block__body::-webkit-scrollbar-thumb {
|
||||
background: rgba(15, 118, 110, 0.35);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .code-block__body::-webkit-scrollbar-thumb {
|
||||
background: rgba(45, 212, 191, 0.4);
|
||||
}
|
||||
|
||||
:host ::ng-deep .hljs-keyword,
|
||||
:host ::ng-deep .hljs-selector-tag,
|
||||
:host ::ng-deep .hljs-literal,
|
||||
:host ::ng-deep .hljs-section,
|
||||
:host ::ng-deep .hljs-link {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
:host ::ng-deep .hljs-function .hljs-title,
|
||||
:host ::ng-deep .hljs-title,
|
||||
:host ::ng-deep .hljs-name {
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
:host ::ng-deep .hljs-subst,
|
||||
:host ::ng-deep .hljs-attribute,
|
||||
:host ::ng-deep .hljs-number,
|
||||
:host ::ng-deep .hljs-symbol {
|
||||
color: #a16207;
|
||||
}
|
||||
|
||||
:host ::ng-deep .hljs-string,
|
||||
:host ::ng-deep .hljs-title.class_,
|
||||
:host ::ng-deep .hljs-meta-string {
|
||||
color: #be123c;
|
||||
}
|
||||
|
||||
:host ::ng-deep .hljs-comment,
|
||||
:host ::ng-deep .hljs-quote {
|
||||
color: rgba(100, 116, 139, 0.9);
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .hljs-comment,
|
||||
:host-context(.dark) ::ng-deep .hljs-quote {
|
||||
color: rgba(148, 163, 184, 0.75);
|
||||
}
|
||||
|
||||
:host ::ng-deep .markdown-table {
|
||||
display: inline-block;
|
||||
margin: 1.5rem auto;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(71, 85, 105, 0.25);
|
||||
overflow: hidden;
|
||||
background: rgba(248, 250, 252, 0.75);
|
||||
box-shadow: 0 18px 45px -30px rgba(15, 23, 42, 0.45);
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .markdown-table {
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
border-color: rgba(148, 163, 184, 0.35);
|
||||
box-shadow: 0 25px 60px -40px rgba(15, 23, 42, 0.85);
|
||||
}
|
||||
|
||||
:host ::ng-deep .markdown-table table {
|
||||
border-collapse: collapse;
|
||||
font-size: 0.95rem;
|
||||
width: auto;
|
||||
table-layout: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
:host ::ng-deep .markdown-table thead {
|
||||
background: rgba(15, 118, 110, 0.18);
|
||||
color: #0f172a;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .markdown-table thead {
|
||||
background: rgba(45, 212, 191, 0.25);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
:host ::ng-deep .markdown-table th,
|
||||
:host ::ng-deep .markdown-table td {
|
||||
padding: 0.8rem 1.1rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.25);
|
||||
vertical-align: top;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .markdown-table th,
|
||||
:host-context(.dark) ::ng-deep .markdown-table td {
|
||||
border-bottom-color: rgba(148, 163, 184, 0.35);
|
||||
}
|
||||
|
||||
:host ::ng-deep .markdown-table tbody tr:nth-child(odd) {
|
||||
background: rgba(15, 118, 110, 0.08);
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .markdown-table tbody tr:nth-child(odd) {
|
||||
background: rgba(20, 184, 166, 0.2);
|
||||
}
|
||||
|
||||
:host ::ng-deep .markdown-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
:host ::ng-deep .markdown-table code.inline-code {
|
||||
background: rgba(15, 118, 110, 0.15);
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .markdown-table code.inline-code {
|
||||
background: rgba(45, 212, 191, 0.22);
|
||||
}
|
||||
|
||||
:host ::ng-deep .md-heading {
|
||||
font-family: "Inter", "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
:host ::ng-deep .md-heading-1 {
|
||||
font-size: clamp(2.8rem, 4vw, 3.5rem);
|
||||
margin-top: 3.5rem;
|
||||
margin-bottom: 1.4rem;
|
||||
border-bottom-width: 3px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
:host ::ng-deep .md-heading-2 {
|
||||
font-size: clamp(2.2rem, 3.2vw, 2.8rem);
|
||||
margin-top: 3rem;
|
||||
margin-bottom: 1.2rem;
|
||||
border-bottom-width: 2px;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
:host ::ng-deep .md-heading-3 {
|
||||
font-size: clamp(1.8rem, 2.8vw, 2.2rem);
|
||||
margin-top: 2.6rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
:host ::ng-deep .md-heading-4 {
|
||||
font-size: clamp(1.5rem, 2.2vw, 1.8rem);
|
||||
margin-top: 2.4rem;
|
||||
margin-bottom: 0.9rem;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
:host ::ng-deep .md-heading-5 {
|
||||
font-size: clamp(1.3rem, 1.8vw, 1.4rem);
|
||||
margin-top: 2.2rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
:host ::ng-deep .md-heading-6 {
|
||||
font-size: clamp(1.1rem, 1.6vw, 1.2rem);
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 0.6rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: uppercase;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .md-heading-1 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .md-heading-2,
|
||||
:host-context(.dark) ::ng-deep .md-heading-3,
|
||||
:host-context(.dark) ::ng-deep .md-heading-4,
|
||||
:host-context(.dark) ::ng-deep .md-heading-5,
|
||||
:host-context(.dark) ::ng-deep .md-heading-6 {
|
||||
color: #5eead4;
|
||||
}
|
||||
|
||||
:host ::ng-deep .metadata-panel {
|
||||
margin-bottom: 2.2rem;
|
||||
padding: 0.2rem 0 0.2rem 0.8rem;
|
||||
border-left: 3px solid rgba(15, 118, 110, 0.5);
|
||||
display: block;
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .metadata-panel {
|
||||
border-left-color: rgba(45, 212, 191, 0.65);
|
||||
}
|
||||
|
||||
:host ::ng-deep .metadata-panel__grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
:host ::ng-deep .metadata-panel__item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.35rem 0;
|
||||
}
|
||||
|
||||
:host ::ng-deep .metadata-panel__icon {
|
||||
font-size: 0.95rem;
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
border-radius: 0.45rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(15, 118, 110, 0.12);
|
||||
color: #0f766e;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .metadata-panel__icon {
|
||||
background: rgba(45, 212, 191, 0.18);
|
||||
color: #5eead4;
|
||||
}
|
||||
|
||||
:host ::ng-deep .metadata-panel__details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:host ::ng-deep .metadata-panel__label {
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
color: rgba(15, 23, 42, 0.55);
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .metadata-panel__label {
|
||||
color: rgba(226, 232, 240, 0.7);
|
||||
}
|
||||
|
||||
:host ::ng-deep .metadata-panel__value,
|
||||
:host ::ng-deep .metadata-panel__link {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: #0f172a;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .metadata-panel__value,
|
||||
:host-context(.dark) ::ng-deep .metadata-panel__link {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
:host ::ng-deep .metadata-panel__link {
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid rgba(15, 118, 110, 0.25);
|
||||
padding-bottom: 0.02rem;
|
||||
}
|
||||
|
||||
:host ::ng-deep .metadata-panel__link:hover,
|
||||
:host ::ng-deep .metadata-panel__link:focus-visible {
|
||||
border-color: rgba(15, 118, 110, 0.6);
|
||||
}
|
||||
|
||||
:host ::ng-deep .metadata-panel__image {
|
||||
width: 78px;
|
||||
max-width: 100%;
|
||||
border-radius: 0.45rem;
|
||||
object-fit: cover;
|
||||
border: 1px solid rgba(148, 163, 184, 0.32);
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .metadata-panel__image {
|
||||
border-color: rgba(71, 85, 105, 0.5);
|
||||
}
|
||||
|
||||
:host ::ng-deep .metadata-panel__value--boolean {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:host ::ng-deep .metadata-panel__boolean-indicator {
|
||||
width: 0.85rem;
|
||||
height: 0.85rem;
|
||||
border-radius: 0.24rem;
|
||||
border: 2px solid rgba(15, 118, 110, 0.32);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:host ::ng-deep .metadata-panel__boolean-indicator.is-checked {
|
||||
background: rgba(15, 118, 110, 0.28);
|
||||
border-color: rgba(15, 118, 110, 0.55);
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .metadata-panel__boolean-indicator {
|
||||
border-color: rgba(45, 212, 191, 0.4);
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .metadata-panel__boolean-indicator.is-checked {
|
||||
background: rgba(45, 212, 191, 0.28);
|
||||
border-color: rgba(45, 212, 191, 0.5);
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
<!-- ObsiViewer - Application optimisée pour mobile et desktop -->
|
||||
<main class="relative flex min-h-screen flex-col bg-obs-l-bg-main text-obs-l-text-main dark:bg-obs-d-bg-main dark:text-obs-d-text-main lg:flex-row lg:h-screen lg:overflow-hidden">
|
||||
<!-- Navigation latérale desktop -->
|
||||
<nav class="hidden w-14 flex-col items-center gap-4 border-r border-obs-l-border bg-obs-l-bg-main py-4 dark:border-obs-d-border dark:bg-obs-d-bg-main lg:flex">
|
||||
<button
|
||||
(click)="setView('files')"
|
||||
class="rounded-lg p-2 transition hover:bg-obs-l-bg-secondary dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.bg-obs-l-bg-secondary]="activeView() === 'files'"
|
||||
[class.dark:bg-obs-d-bg-secondary]="activeView() === 'files'"
|
||||
aria-label="Afficher les fichiers"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 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" /></svg>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('search')"
|
||||
class="rounded-lg p-2 transition hover:bg-obs-l-bg-secondary dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.bg-obs-l-bg-secondary]="activeView() === 'search'"
|
||||
[class.dark:bg-obs-d-bg-secondary]="activeView() === 'search'"
|
||||
aria-label="Ouvrir la recherche"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('tags')"
|
||||
class="rounded-lg p-2 transition hover:bg-obs-l-bg-secondary dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.bg-obs-l-bg-secondary]="activeView() === 'tags'"
|
||||
[class.dark:bg-obs-d-bg-secondary]="activeView() === 'tags'"
|
||||
aria-label="Afficher les tags"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 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="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-5 5a2 2 0 01-2.828 0l-7-7A2 2 0 013 8V3z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('graph')"
|
||||
class="rounded-lg p-2 transition hover:bg-obs-l-bg-secondary dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.bg-obs-l-bg-secondary]="activeView() === 'graph'"
|
||||
[class.dark:bg-obs-d-bg-secondary]="activeView() === 'graph'"
|
||||
aria-label="Afficher la vue graphe"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 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="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('calendar')"
|
||||
class="rounded-lg p-2 transition hover:bg-obs-l-bg-secondary dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.bg-obs-l-bg-secondary]="activeView() === 'calendar'"
|
||||
[class.dark:bg-obs-d-bg-secondary]="activeView() === 'calendar'"
|
||||
aria-label="Afficher le calendrier"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 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="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2h-1.5a1.5 1.5 0 01-3 0h-5a1.5 1.5 0 01-3 0H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<nav class="sticky bottom-0 z-30 flex w-full items-center justify-around gap-2 border-t border-obs-l-border bg-obs-l-bg-main/95 px-2 py-2 backdrop-blur-xs dark:border-obs-d-border dark:bg-obs-d-bg-main/95 lg:hidden">
|
||||
<button
|
||||
(click)="setView('files'); toggleSidebarTo(true)"
|
||||
class="flex flex-1 flex-col items-center gap-1 rounded-lg px-3 py-2 text-xs font-semibold text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.text-obs-l-text-main]="activeView() === 'files'"
|
||||
[class.dark:text-obs-d-text-main]="activeView() === 'files'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" 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" /></svg>
|
||||
Fichiers
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('search'); toggleSidebarTo(true)"
|
||||
class="flex flex-1 flex-col items-center gap-1 rounded-lg px-3 py-2 text-xs font-semibold text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.text-obs-l-text-main]="activeView() === 'search'"
|
||||
[class.dark:text-obs-d-text-main]="activeView() === 'search'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||
Recherche
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('tags'); toggleSidebarTo(true)"
|
||||
class="flex flex-1 flex-col items-center gap-1 rounded-lg px-3 py-2 text-xs font-semibold text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.text-obs-l-text-main]="activeView() === 'tags'"
|
||||
[class.dark:text-obs-d-text-main]="activeView() === 'tags'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-5 5a2 2 0 01-2.828 0l-7-7A2 2 0 013 8V3z" /></svg>
|
||||
Tags
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('calendar'); toggleSidebarTo(true)"
|
||||
class="flex flex-1 flex-col items-center gap-1 rounded-lg px-3 py-2 text-xs font-semibold text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.text-obs-l-text-main]="activeView() === 'calendar'"
|
||||
[class.dark:text-obs-d-text-main]="activeView() === 'calendar'"
|
||||
[attr.aria-pressed]="activeView() === 'calendar'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2h-1.5a1.5 1.5 0 01-3 0h-5a1.5 1.5 0 01-3 0H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
Agenda
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@if (isDesktop() || isSidebarOpen()) {
|
||||
<aside
|
||||
class="fixed inset-y-0 left-0 z-40 flex h-screen w-[min(320px,85vw)] -translate-x-full transform flex-col border-r border-obs-l-border bg-obs-l-bg-secondary shadow-xl transition-transform duration-200 ease-in-out dark:border-obs-d-border dark:bg-obs-d-bg-secondary lg:static lg:h-auto lg:w-auto lg:translate-x-0 lg:shadow-none"
|
||||
[class.translate-x-0]="isSidebarOpen() || isDesktop()"
|
||||
[class.pointer-events-none]="!isSidebarOpen() && !isDesktop()"
|
||||
[style.width.px]="isDesktop() ? (isSidebarOpen() ? leftSidebarWidth() : 0) : null"
|
||||
[style.minWidth.px]="isDesktop() ? (isSidebarOpen() ? leftSidebarWidth() : 0) : null"
|
||||
[style.maxWidth.px]="isDesktop() ? (isSidebarOpen() ? leftSidebarWidth() : 0) : null"
|
||||
role="navigation"
|
||||
aria-label="Arborescence de la voûte"
|
||||
>
|
||||
<div class="flex h-full flex-col overflow-hidden"
|
||||
[style.width.px]="isDesktop() ? (isSidebarOpen() ? leftSidebarWidth() : 0) : null"
|
||||
>
|
||||
<div class="space-y-4 border-b border-obs-l-border bg-obs-l-bg-secondary/60 px-4 py-4 dark:border-obs-d-border dark:bg-obs-d-bg-secondary/60">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-2xl border border-obs-l-border/60 bg-obs-l-bg-main/70 text-obs-l-text-muted shadow-sm dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/60 dark:text-obs-d-text-muted">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM5.5 21a6.5 6.5 0 0113 0" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm font-semibold tracking-wide text-obs-l-text-main dark:text-obs-d-text-main">{{ vaultName() }}</span>
|
||||
<div class="mt-1 flex items-center gap-2 text-xs uppercase text-obs-l-text-muted dark:text-obs-d-text-muted">
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-obs-l-bg-main/70 px-2 py-0.5 text-[0.65rem] font-semibold tracking-widest text-obs-l-text-main/80 dark:bg-obs-d-bg-main/60 dark:text-obs-d-text-main/80">{{ activeView() | titlecase }}</span>
|
||||
<span class="hidden text-[0.65rem] tracking-widest text-obs-l-text-muted/80 dark:text-obs-d-text-muted/70 sm:inline">Vue active</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!isDesktop()) {
|
||||
<button
|
||||
class="rounded-xl border border-transparent bg-obs-l-bg-main/70 p-2 text-obs-l-text-muted transition hover:border-obs-l-border/50 hover:bg-obs-l-bg-main/90 dark:bg-obs-d-bg-main/60 dark:text-obs-d-text-muted dark:hover:border-obs-d-border/60 dark:hover:bg-obs-d-bg-main/70"
|
||||
(click)="closeSidebar()"
|
||||
aria-label="Fermer le panneau latéral"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-obs-l-border/60 bg-obs-l-bg-main/75 px-3 py-3 shadow-inner dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/60">
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<button
|
||||
(click)="setView('files')"
|
||||
class="group flex flex-col items-center gap-1 rounded-xl border border-transparent px-2.5 py-2 text-xs font-medium text-obs-l-text-muted transition duration-150 hover:border-obs-l-border/50 hover:bg-obs-l-bg-main/60 hover:text-obs-l-text-main dark:text-obs-d-text-muted dark:hover:border-obs-d-border/50 dark:hover:bg-obs-d-bg-main/60 dark:hover:text-obs-d-text-main"
|
||||
[ngClass]="{ 'bg-obs-l-bg-main/80 text-obs-l-text-main shadow-sm dark:bg-obs-d-bg-main/70 dark:text-obs-d-text-main': activeView() === 'files' }"
|
||||
aria-label="Afficher les fichiers"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" 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" /></svg>
|
||||
<span>Fichiers</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('search')"
|
||||
class="group flex flex-col items-center gap-1 rounded-xl border border-transparent px-2.5 py-2 text-xs font-medium text-obs-l-text-muted transition duration-150 hover:border-obs-l-border/50 hover:bg-obs-l-bg-main/60 hover:text-obs-l-text-main dark:text-obs-d-text-muted dark:hover:border-obs-d-border/50 dark:hover:bg-obs-d-bg-main/60 dark:hover:text-obs-d-text-main"
|
||||
[ngClass]="{ 'bg-obs-l-bg-main/80 text-obs-l-text-main shadow-sm dark:bg-obs-d-bg-main/70 dark:text-obs-d-text-main': activeView() === 'search' }"
|
||||
aria-label="Ouvrir la recherche"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||
<span>Recherche</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('tags')"
|
||||
class="group flex flex-col items-center gap-1 rounded-xl border border-transparent px-2.5 py-2 text-xs font-medium text-obs-l-text-muted transition duration-150 hover:border-obs-l-border/50 hover:bg-obs-l-bg-main/60 hover:text-obs-l-text-main dark:text-obs-d-text-muted dark:hover:border-obs-d-border/50 dark:hover:bg-obs-d-bg-main/60 dark:hover:text-obs-d-text-main"
|
||||
[ngClass]="{ 'bg-obs-l-bg-main/80 text-obs-l-text-main shadow-sm dark:bg-obs-d-bg-main/70 dark:text-obs-d-text-main': activeView() === 'tags' }"
|
||||
aria-label="Afficher les tags"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-5 5a2 2 0 01-2.828 0l-7-7A2 2 0 013 8V3z" /></svg>
|
||||
<span>Tags</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('calendar')"
|
||||
class="group flex flex-col items-center gap-1 rounded-xl border border-transparent px-2.5 py-2 text-xs font-medium text-obs-l-text-muted transition duration-150 hover:border-obs-l-border/50 hover:bg-obs-l-bg-main/60 hover:text-obs-l-text-main dark:text-obs-d-text-muted dark:hover:border-obs-d-border/50 dark:hover:bg-obs-d-bg-main/60 dark:hover:text-obs-d-text-main"
|
||||
[ngClass]="{ 'bg-obs-l-bg-main/80 text-obs-l-text-main shadow-sm dark:bg-obs-d-bg-main/70 dark:text-obs-d-text-main': activeView() === 'calendar' }"
|
||||
[attr.aria-pressed]="activeView() === 'calendar'"
|
||||
aria-label="Afficher l'agenda"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2h-1.5a1.5 1.5 0 01-3 0h-5a1.5 1.5 0 01-3 0H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
<span>Agenda</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
@switch (activeView()) {
|
||||
@case ('files') {
|
||||
<app-file-explorer
|
||||
[nodes]="filteredFileTree()"
|
||||
[selectedNoteId]="selectedNoteId()"
|
||||
(fileSelected)="selectNote($event)"
|
||||
></app-file-explorer>
|
||||
}
|
||||
@case ('tags') {
|
||||
<app-tags-view [tags]="filteredTags()" (tagSelected)="handleTagClick($event)"></app-tags-view>
|
||||
}
|
||||
@case ('graph') {
|
||||
<div class="h-full p-2"><app-graph-view [graphData]="graphData()" (nodeSelected)="selectNote($event)"></app-graph-view></div>
|
||||
}
|
||||
@case ('search') {
|
||||
<div class="space-y-2 p-3">
|
||||
@if (activeTagFilter(); as tagFilter) {
|
||||
<div class="flex items-center justify-between rounded-lg border border-obs-l-border/60 bg-obs-l-bg-main/70 px-3 py-2 dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/70">
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-obs-l-text-main dark:text-obs-d-text-main">
|
||||
<span>🔖</span>
|
||||
<span class="truncate">#{{ tagFilter }}</span>
|
||||
</div>
|
||||
<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" (click)="clearTagFilter()">Effacer</button>
|
||||
</div>
|
||||
}
|
||||
<h3 class="px-2 py-1 text-sm font-semibold text-obs-l-text-muted dark:text-obs-d-text-muted">{{ searchResults().length }} résultats</h3>
|
||||
<ul class="space-y-1">
|
||||
@for (note of searchResults(); track note.id) {
|
||||
<li
|
||||
(click)="selectNote(note.id)"
|
||||
class="cursor-pointer rounded px-2 py-2 transition hover:bg-obs-l-bg-main dark:hover:bg-obs-d-bg-main"
|
||||
[class.bg-obs-l-bg-main]="selectedNoteId() === note.id"
|
||||
[class.dark:bg-obs-d-bg-main]="selectedNoteId() === note.id"
|
||||
>
|
||||
<div class="truncate font-semibold">{{ note.title }}</div>
|
||||
<div class="truncate text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">
|
||||
{{ note.content.substring(0, 100) }}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@if (calendarSelectionLabel() || calendarSearchState() !== 'idle' || calendarResults().length > 0 || calendarSearchError()) {
|
||||
<div class="mt-4 space-y-2 border-t border-obs-l-border/60 pt-3 dark:border-obs-d-border/60">
|
||||
<div class="flex items-center justify-between px-2">
|
||||
<h3 class="text-sm font-semibold text-obs-l-text-muted dark:text-obs-d-text-muted">Résultats du calendrier</h3>
|
||||
@if (calendarSelectionLabel(); as selectionLabel) {
|
||||
<span class="text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">{{ selectionLabel }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (calendarSearchState() === 'loading') {
|
||||
<div class="px-2 py-2 text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">Recherche en cours...</div>
|
||||
} @else if (calendarSearchError(); as calError) {
|
||||
<div class="px-2 py-2 text-xs text-red-500 dark:text-red-400">{{ calError }}</div>
|
||||
} @else if (calendarResults().length === 0) {
|
||||
<div class="px-2 py-2 text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">Sélectionnez un jour ou une plage dans le calendrier.</div>
|
||||
} @else {
|
||||
<ul class="mt-2 space-y-1">
|
||||
@for (file of calendarResults(); track file.id) {
|
||||
<li>
|
||||
<button
|
||||
(click)="selectNote(file.id)"
|
||||
class="w-full rounded-lg bg-obs-l-bg-main/80 px-2 py-2 text-left transition hover:bg-obs-l-bg-main dark:bg-obs-d-bg-main/60 dark:hover:bg-obs-d-bg-main"
|
||||
>
|
||||
<div class="truncate text-sm font-semibold text-obs-l-text-main dark:text-obs-d-text-main">{{ file.title }}</div>
|
||||
<div class="flex flex-wrap gap-2 text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">
|
||||
<span>Créé : {{ file.createdAt | date:'mediumDate' }}</span>
|
||||
<span>Modifié : {{ file.updatedAt | date:'mediumDate' }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@case ('calendar') {
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="flex items-center justify-between border-b border-obs-l-border/60 bg-obs-l-bg-main/70 px-4 py-3 dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/60">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-obs-l-text-main dark:text-obs-d-text-main">Vue agenda</h3>
|
||||
@if (calendarSelectionLabel(); as selectionLabel) {
|
||||
<p class="mt-1 text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">{{ selectionLabel }}</p>
|
||||
} @else {
|
||||
<p class="mt-1 text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">Sélectionnez un jour ou une plage pour voir les notes correspondantes.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex-1 px-3 py-4">
|
||||
<div
|
||||
class="rounded-2xl border border-obs-l-border/50 bg-obs-l-bg-main/80 p-3 shadow-sm transition duration-200 dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/70"
|
||||
[class.opacity-0]="calendarOverlayVisible()"
|
||||
[class.pointer-events-none]="calendarOverlayVisible()"
|
||||
>
|
||||
<app-markdown-calendar
|
||||
(fileSelected)="selectNote($event)"
|
||||
(searchResultsChange)="onCalendarResultsChange($event)"
|
||||
(searchStateChange)="onCalendarSearchStateChange($event)"
|
||||
(searchErrorChange)="onCalendarSearchErrorChange($event)"
|
||||
(selectionSummaryChange)="onCalendarSelectionSummaryChange($event)"
|
||||
(requestSearchPanel)="onCalendarRequestSearchPanel()"
|
||||
></app-markdown-calendar>
|
||||
</div>
|
||||
@if (calendarOverlayVisible()) {
|
||||
<div class="absolute inset-0 flex flex-col overflow-hidden rounded-2xl border border-obs-l-border/60 bg-obs-l-bg-main/95 shadow-2xl dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/95">
|
||||
<div class="flex items-center justify-between border-b border-obs-l-border/60 px-4 py-3 dark:border-obs-d-border/60">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h4 class="text-sm font-semibold text-obs-l-text-main dark:text-obs-d-text-main">Notes correspondantes</h4>
|
||||
@if (calendarSelectionLabel(); as selectionLabel) {
|
||||
<span class="text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">{{ selectionLabel }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
@if (calendarResults().length > 0) {
|
||||
<span class="text-xs font-medium text-obs-l-text-muted dark:text-obs-d-text-muted">{{ calendarResults().length }} {{ calendarResults().length > 1 ? 'notes' : 'note' }}</span>
|
||||
}
|
||||
<button
|
||||
(click)="clearCalendarResults()"
|
||||
class="rounded-lg border border-transparent p-1.5 text-obs-l-text-muted transition hover:border-obs-l-border/60 hover:bg-obs-l-bg-main/80 hover:text-obs-l-text-main dark:text-obs-d-text-muted dark:hover:border-obs-d-border/60 dark:hover:bg-obs-d-bg-main/70 dark:hover:text-obs-d-text-main"
|
||||
aria-label="Fermer les résultats du calendrier"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 space-y-2 overflow-y-auto px-4 py-4">
|
||||
@if (calendarSearchState() === 'loading') {
|
||||
<div class="rounded-lg bg-obs-l-bg-main/70 px-3 py-3 text-sm text-obs-l-text-muted dark:bg-obs-d-bg-main/70 dark:text-obs-d-text-muted">Recherche en cours...</div>
|
||||
} @else if (calendarSearchError(); as calError) {
|
||||
<div class="rounded-lg border border-red-500/40 bg-red-500/10 px-3 py-3 text-sm text-red-500 dark:border-red-500/30 dark:bg-red-500/15 dark:text-red-400">{{ calError }}</div>
|
||||
} @else if (calendarResults().length === 0) {
|
||||
<div class="rounded-lg bg-obs-l-bg-main/70 px-3 py-3 text-sm text-obs-l-text-muted dark:bg-obs-d-bg-main/70 dark:text-obs-d-text-muted">Sélectionnez une période pour afficher les notes.</div>
|
||||
} @else {
|
||||
<ul class="space-y-2">
|
||||
@for (file of calendarResults(); track file.id) {
|
||||
<li>
|
||||
<button
|
||||
(click)="selectNote(file.id)"
|
||||
class="w-full rounded-xl border border-transparent bg-obs-l-bg-main/85 px-3 py-3 text-left transition hover:border-obs-l-border/60 hover:bg-obs-l-bg-main dark:bg-obs-d-bg-main/70 dark:hover:border-obs-d-border/60 dark:hover:bg-obs-d-bg-main"
|
||||
>
|
||||
<div class="truncate text-sm font-semibold text-obs-l-text-main dark:text-obs-d-text-main">{{ file.title }}</div>
|
||||
<div class="mt-1 flex flex-wrap gap-2 text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">
|
||||
<span>Créé : {{ file.createdAt | date:'mediumDate' }}</span>
|
||||
<span>Modifié : {{ file.updatedAt | date:'mediumDate' }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="border-t border-obs-l-border/60 bg-obs-l-bg-main/60 px-4 py-4 dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/60">
|
||||
@if (calendarOverlayVisible()) {
|
||||
<p class="text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">Fermez le panneau pour revenir au calendrier ou sélectionner une nouvelle période.</p>
|
||||
} @else {
|
||||
<p class="text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">Choisissez une date pour afficher les notes associées.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
}
|
||||
@if (!isDesktop() && isSidebarOpen()) {
|
||||
<div
|
||||
class="fixed inset-0 z-30 bg-black/40 transition-opacity duration-200"
|
||||
(click)="closeSidebar()"
|
||||
></div>
|
||||
}
|
||||
|
||||
<div class="hidden lg:flex">
|
||||
<div
|
||||
class="resize-handle h-full"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Redimensionner la barre latérale gauche"
|
||||
(pointerdown)="startLeftResize($event)"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<section class="flex min-w-0 flex-col bg-obs-l-bg-main pb-16 dark:bg-obs-d-bg-main lg:flex-1 lg:min-h-0 lg:overflow-hidden lg:pb-0">
|
||||
<header class="flex flex-col gap-4 border-b border-obs-l-border/60 bg-obs-l-bg-main/95 px-4 py-3 backdrop-blur-xs dark:border-obs-d-border dark:bg-obs-d-bg-main/95 lg:px-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<button
|
||||
class="rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary lg:hidden"
|
||||
(click)="toggleSidebar()"
|
||||
[attr.aria-expanded]="isSidebarOpen()"
|
||||
aria-label="Basculer le menu"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /></svg>
|
||||
</button>
|
||||
<button
|
||||
class="hidden rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary lg:inline-flex"
|
||||
(click)="toggleSidebar()"
|
||||
[attr.aria-expanded]="isSidebarOpen()"
|
||||
aria-label="Afficher ou masquer la barre latérale gauche"
|
||||
>
|
||||
@if (isSidebarOpen()) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/><path d="m16 15-3-3 3-3"/></svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/><path d="m14 9 3 3-3 3"/></svg>
|
||||
}
|
||||
</button>
|
||||
<div class="min-w-0 flex flex-col gap-1">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="inline-flex items-center rounded-lg border border-obs-l-border bg-obs-l-bg-secondary/60 px-2.5 py-1 text-xs font-semibold uppercase tracking-wide text-obs-l-text-muted dark:border-obs-d-border dark:bg-obs-d-bg-secondary/60 dark:text-obs-d-text-muted">{{ vaultName() }}</span>
|
||||
<h1 class="text-lg font-semibold leading-tight text-obs-l-text-main dark:text-obs-d-text-main">ObsiWatcher</h1>
|
||||
</div>
|
||||
@if (selectedNoteBreadcrumb().length > 0) {
|
||||
<p class="truncate text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">{{ selectedNoteBreadcrumb().join(' / ') }}</p>
|
||||
} @else {
|
||||
<p class="truncate text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">Aucune note sélectionnée</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden items-center gap-2 lg:flex">
|
||||
<button
|
||||
(click)="toggleTheme()"
|
||||
class="rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
aria-label="Basculer le thème"
|
||||
>
|
||||
@if (isDarkMode()) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
(click)="toggleOutline()"
|
||||
class="rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
[attr.aria-expanded]="isOutlineOpen()"
|
||||
aria-label="Basculer la barre latérale droite"
|
||||
>
|
||||
@if (isOutlineOpen()) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/><path d="m8 9 3 3-3 3"/></svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/><path d="m10 15-3-3 3-3"/></svg>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between lg:gap-6">
|
||||
<div class="flex items-center gap-2 lg:hidden">
|
||||
<button
|
||||
(click)="toggleTheme()"
|
||||
class="rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
aria-label="Basculer le thème"
|
||||
>
|
||||
@if (isDarkMode()) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
(click)="toggleOutline()"
|
||||
class="rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
[attr.aria-expanded]="isOutlineOpen()"
|
||||
aria-label="Basculer le calendrier"
|
||||
>
|
||||
@if (isOutlineOpen()) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/><path d="m8 9 3 3-3 3"/></svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/><path d="m10 15-3-3 3-3"/></svg>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative w-full max-w-2xl lg:max-w-3xl">
|
||||
<svg class="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 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-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher dans la voûte..."
|
||||
[(ngModel)]="sidebarSearchTerm"
|
||||
(ngModelChange)="activeView.set('search')"
|
||||
class="w-full rounded-full border border-obs-l-border bg-obs-l-bg-secondary/60 pl-11 pr-4 py-2.5 text-sm text-obs-l-text-main placeholder:text-obs-l-text-muted shadow-sm focus:outline-none focus:ring-2 focus:ring-obs-l-accent dark:border-obs-d-border dark:bg-obs-d-bg-secondary/60 dark:text-obs-d-text-main dark:placeholder:text-obs-d-text-muted dark:focus:ring-obs-d-accent"
|
||||
aria-label="Rechercher dans la voûte"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="min-h-0 px-4 py-6 note-content-area lg:flex-1 lg:overflow-y-auto lg:px-8">
|
||||
@if (selectedNote(); as note) {
|
||||
<app-note-viewer
|
||||
[note]="note"
|
||||
[noteHtmlContent]="renderedNoteContent()"
|
||||
(noteLinkClicked)="selectNote($event)"
|
||||
(tagClicked)="handleTagClick($event)"
|
||||
></app-note-viewer>
|
||||
} @else {
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<p class="text-obs-l-text-muted dark:text-obs-d-text-muted">Sélectionnez une note pour commencer</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="hidden lg:flex">
|
||||
<div
|
||||
class="resize-handle h-full"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Redimensionner la barre latérale droite"
|
||||
(pointerdown)="startRightResize($event)"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
@if (!isDesktop() && isOutlineOpen()) {
|
||||
<div
|
||||
class="fixed inset-0 z-30 bg-black/40 transition-opacity duration-200"
|
||||
(click)="closeOutlinePanel()"
|
||||
></div>
|
||||
}
|
||||
|
||||
@if (isDesktop() || isOutlineOpen()) {
|
||||
<aside
|
||||
class="fixed inset-x-0 bottom-0 z-40 flex max-h-[80vh] flex-col overflow-hidden border-t border-obs-l-border bg-obs-l-bg-secondary shadow-2xl transition-all duration-200 ease-out dark:border-obs-d-border dark:bg-obs-d-bg-secondary lg:static lg:max-h-none lg:border-l lg:shadow-none"
|
||||
[ngClass]="{
|
||||
'translate-y-0 opacity-100 pointer-events-auto': isOutlineOpen() || isDesktop(),
|
||||
'translate-y-full opacity-0 pointer-events-none': !isOutlineOpen() && !isDesktop()
|
||||
}"
|
||||
[style.width.px]="isDesktop() ? (isOutlineOpen() ? rightSidebarWidth() : 0) : null"
|
||||
[style.minWidth.px]="isDesktop() ? (isOutlineOpen() ? rightSidebarWidth() : 0) : null"
|
||||
[style.maxWidth.px]="isDesktop() ? (isOutlineOpen() ? rightSidebarWidth() : 0) : null"
|
||||
role="complementary"
|
||||
aria-label="Table des matières et calendrier"
|
||||
>
|
||||
<div class="flex flex-col lg:h-full">
|
||||
@if (!isDesktop()) {
|
||||
<div class="px-4 pt-3">
|
||||
<div class="mx-auto mb-3 h-1.5 w-12 rounded-full bg-obs-l-border dark:bg-obs-d-border"></div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold text-obs-l-text-main dark:text-obs-d-text-main">Navigation</h2>
|
||||
<button
|
||||
class="rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-main/70 dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-main/60"
|
||||
(click)="closeOutlinePanel()"
|
||||
aria-label="Fermer le calendrier"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs uppercase tracking-wide text-obs-l-text-muted dark:text-obs-d-text-muted">Table des matières & calendrier</p>
|
||||
</div>
|
||||
}
|
||||
<div class="hidden border-b border-obs-l-border px-4 py-3 text-sm font-semibold uppercase tracking-wide text-obs-l-text-muted dark:border-obs-d-border dark:text-obs-d-text-muted lg:block">
|
||||
Outline
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto px-4 py-4">
|
||||
@if (tableOfContents().length > 0) {
|
||||
<ul class="space-y-2">
|
||||
@for (entry of tableOfContents(); track entry.id) {
|
||||
<li [style.padding-left.rem]="(entry.level - 1) * 0.75" class="flex items-start gap-2 text-sm text-obs-l-text-muted dark:text-obs-d-text-muted">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="mt-1 h-3 w-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" /></svg>
|
||||
<a (click)="scrollToHeading(entry.id)" class="cursor-pointer leading-tight transition hover:text-obs-l-text-main dark:hover:text-obs-d-text-main">
|
||||
{{ entry.text }}
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p class="text-sm italic text-obs-l-text-muted dark:text-obs-d-text-muted">Aucun titre dans cette note.</p>
|
||||
}
|
||||
</div>
|
||||
<div class="border-t border-obs-l-border bg-obs-l-bg-secondary/60 p-4 dark:border-obs-d-border dark:bg-obs-d-bg-secondary/60">
|
||||
<app-markdown-calendar
|
||||
(fileSelected)="selectNote($event)"
|
||||
(searchResultsChange)="onCalendarResultsChange($event)"
|
||||
(searchStateChange)="onCalendarSearchStateChange($event)"
|
||||
(searchErrorChange)="onCalendarSearchErrorChange($event)"
|
||||
(selectionSummaryChange)="onCalendarSelectionSummaryChange($event)"
|
||||
(requestSearchPanel)="onCalendarRequestSearchPanel()"
|
||||
></app-markdown-calendar>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
}
|
||||
</main>
|
||||
@@ -0,0 +1,550 @@
|
||||
<!-- ObsiViewer - Application optimisée pour mobile et desktop -->
|
||||
<main class="relative flex min-h-screen flex-col bg-obs-l-bg-main text-obs-l-text-main dark:bg-obs-d-bg-main dark:text-obs-d-text-main lg:flex-row lg:h-screen lg:overflow-hidden">
|
||||
<!-- Navigation latérale desktop -->
|
||||
<nav class="hidden w-14 flex-col items-center gap-4 border-r border-obs-l-border bg-obs-l-bg-main py-4 dark:border-obs-d-border dark:bg-obs-d-bg-main lg:flex">
|
||||
<button
|
||||
(click)="setView('files')"
|
||||
class="rounded-lg p-2 transition hover:bg-obs-l-bg-secondary dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.bg-obs-l-bg-secondary]="activeView() === 'files'"
|
||||
[class.dark:bg-obs-d-bg-secondary]="activeView() === 'files'"
|
||||
aria-label="Afficher les fichiers"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 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" /></svg>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('search')"
|
||||
class="rounded-lg p-2 transition hover:bg-obs-l-bg-secondary dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.bg-obs-l-bg-secondary]="activeView() === 'search'"
|
||||
[class.dark:bg-obs-d-bg-secondary]="activeView() === 'search'"
|
||||
aria-label="Ouvrir la recherche"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('tags')"
|
||||
class="rounded-lg p-2 transition hover:bg-obs-l-bg-secondary dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.bg-obs-l-bg-secondary]="activeView() === 'tags'"
|
||||
[class.dark:bg-obs-d-bg-secondary]="activeView() === 'tags'"
|
||||
aria-label="Afficher les tags"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 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="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-5 5a2 2 0 01-2.828 0l-7-7A2 2 0 013 8V3z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('graph')"
|
||||
class="rounded-lg p-2 transition hover:bg-obs-l-bg-secondary dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.bg-obs-l-bg-secondary]="activeView() === 'graph'"
|
||||
[class.dark:bg-obs-d-bg-secondary]="activeView() === 'graph'"
|
||||
aria-label="Afficher la vue graphe"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 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="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<nav class="sticky bottom-0 z-30 flex w-full items-center justify-around gap-2 border-t border-obs-l-border bg-obs-l-bg-main/95 px-2 py-2 backdrop-blur-xs dark:border-obs-d-border dark:bg-obs-d-bg-main/95 lg:hidden">
|
||||
<button
|
||||
(click)="setView('files'); toggleSidebarTo(true)"
|
||||
class="flex flex-1 flex-col items-center gap-1 rounded-lg px-3 py-2 text-xs font-semibold text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.text-obs-l-text-main]="activeView() === 'files'"
|
||||
[class.dark:text-obs-d-text-main]="activeView() === 'files'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" 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" /></svg>
|
||||
Fichiers
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('search'); toggleSidebarTo(true)"
|
||||
class="flex flex-1 flex-col items-center gap-1 rounded-lg px-3 py-2 text-xs font-semibold text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.text-obs-l-text-main]="activeView() === 'search'"
|
||||
[class.dark:text-obs-d-text-main]="activeView() === 'search'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||
Recherche
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('tags'); toggleSidebarTo(true)"
|
||||
class="flex flex-1 flex-col items-center gap-1 rounded-lg px-3 py-2 text-xs font-semibold text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.text-obs-l-text-main]="activeView() === 'tags'"
|
||||
[class.dark:text-obs-d-text-main]="activeView() === 'tags'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-5 5a2 2 0 01-2.828 0l-7-7A2 2 0 013 8V3z" /></svg>
|
||||
Tags
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('calendar'); toggleSidebarTo(true)"
|
||||
class="flex flex-1 flex-col items-center gap-1 rounded-lg px-3 py-2 text-xs font-semibold text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.text-obs-l-text-main]="activeView() === 'calendar'"
|
||||
[class.dark:text-obs-d-text-main]="activeView() === 'calendar'"
|
||||
[attr.aria-pressed]="activeView() === 'calendar'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2h-1.5a1.5 1.5 0 01-3 0h-5a1.5 1.5 0 01-3 0H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
Agenda
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@if (isDesktop() || isSidebarOpen()) {
|
||||
<aside
|
||||
class="fixed inset-y-0 left-0 z-40 flex h-screen w-[min(320px,85vw)] -translate-x-full transform flex-col border-r border-obs-l-border bg-obs-l-bg-secondary shadow-xl transition-transform duration-200 ease-in-out dark:border-obs-d-border dark:bg-obs-d-bg-secondary lg:static lg:h-auto lg:w-auto lg:translate-x-0 lg:shadow-none"
|
||||
[class.translate-x-0]="isSidebarOpen() || isDesktop()"
|
||||
[class.pointer-events-none]="!isSidebarOpen() && !isDesktop()"
|
||||
[style.width.px]="isDesktop() ? (isSidebarOpen() ? leftSidebarWidth() : 0) : null"
|
||||
[style.minWidth.px]="isDesktop() ? (isSidebarOpen() ? leftSidebarWidth() : 0) : null"
|
||||
[style.maxWidth.px]="isDesktop() ? (isSidebarOpen() ? leftSidebarWidth() : 0) : null"
|
||||
role="navigation"
|
||||
aria-label="Arborescence de la voûte"
|
||||
>
|
||||
<div class="flex h-full flex-col overflow-hidden"
|
||||
[style.width.px]="isDesktop() ? (isSidebarOpen() ? leftSidebarWidth() : 0) : null"
|
||||
>
|
||||
<div class="space-y-4 border-b border-obs-l-border bg-obs-l-bg-secondary/60 px-4 py-4 dark:border-obs-d-border dark:bg-obs-d-bg-secondary/60">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-2xl border border-obs-l-border/60 bg-obs-l-bg-main/70 text-obs-l-text-muted shadow-sm dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/60 dark:text-obs-d-text-muted">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM5.5 21a6.5 6.5 0 0113 0" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm font-semibold tracking-wide text-obs-l-text-main dark:text-obs-d-text-main">{{ vaultName() }}</span>
|
||||
<div class="mt-1 flex items-center gap-2 text-xs uppercase text-obs-l-text-muted dark:text-obs-d-text-muted">
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-obs-l-bg-main/70 px-2 py-0.5 text-[0.65rem] font-semibold tracking-widest text-obs-l-text-main/80 dark:bg-obs-d-bg-main/60 dark:text-obs-d-text-main/80">{{ activeView() | titlecase }}</span>
|
||||
<span class="hidden text-[0.65rem] tracking-widest text-obs-l-text-muted/80 dark:text-obs-d-text-muted/70 sm:inline">Vue active</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!isDesktop()) {
|
||||
<button
|
||||
class="rounded-xl border border-transparent bg-obs-l-bg-main/70 p-2 text-obs-l-text-muted transition hover:border-obs-l-border/50 hover:bg-obs-l-bg-main/90 dark:bg-obs-d-bg-main/60 dark:text-obs-d-text-muted dark:hover:border-obs-d-border/60 dark:hover:bg-obs-d-bg-main/70"
|
||||
(click)="closeSidebar()"
|
||||
aria-label="Fermer le panneau latéral"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-obs-l-border/60 bg-obs-l-bg-main/75 px-3 py-3 shadow-inner dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/60">
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<button
|
||||
(click)="setView('files')"
|
||||
class="group flex flex-col items-center gap-1 rounded-xl border border-transparent px-2.5 py-2 text-xs font-medium text-obs-l-text-muted transition duration-150 hover:border-obs-l-border/50 hover:bg-obs-l-bg-main/60 hover:text-obs-l-text-main dark:text-obs-d-text-muted dark:hover:border-obs-d-border/50 dark:hover:bg-obs-d-bg-main/60 dark:hover:text-obs-d-text-main"
|
||||
[ngClass]="{ 'bg-obs-l-bg-main/80 text-obs-l-text-main shadow-sm dark:bg-obs-d-bg-main/70 dark:text-obs-d-text-main': activeView() === 'files' }"
|
||||
aria-label="Afficher les fichiers"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" 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" /></svg>
|
||||
<span>Fichiers</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('search')"
|
||||
class="group flex flex-col items-center gap-1 rounded-xl border border-transparent px-2.5 py-2 text-xs font-medium text-obs-l-text-muted transition duration-150 hover:border-obs-l-border/50 hover:bg-obs-l-bg-main/60 hover:text-obs-l-text-main dark:text-obs-d-text-muted dark:hover:border-obs-d-border/50 dark:hover:bg-obs-d-bg-main/60 dark:hover:text-obs-d-text-main"
|
||||
[ngClass]="{ 'bg-obs-l-bg-main/80 text-obs-l-text-main shadow-sm dark:bg-obs-d-bg-main/70 dark:text-obs-d-text-main': activeView() === 'search' }"
|
||||
aria-label="Ouvrir la recherche"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||
<span>Recherche</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('tags')"
|
||||
class="group flex flex-col items-center gap-1 rounded-xl border border-transparent px-2.5 py-2 text-xs font-medium text-obs-l-text-muted transition duration-150 hover:border-obs-l-border/50 hover:bg-obs-l-bg-main/60 hover:text-obs-l-text-main dark:text-obs-d-text-muted dark:hover:border-obs-d-border/50 dark:hover:bg-obs-d-bg-main/60 dark:hover:text-obs-d-text-main"
|
||||
[ngClass]="{ 'bg-obs-l-bg-main/80 text-obs-l-text-main shadow-sm dark:bg-obs-d-bg-main/70 dark:text-obs-d-text-main': activeView() === 'tags' }"
|
||||
aria-label="Afficher les tags"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-5 5a2 2 0 01-2.828 0l-7-7A2 2 0 013 8V3z" /></svg>
|
||||
<span>Tags</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('calendar')"
|
||||
class="group flex flex-col items-center gap-1 rounded-xl border border-transparent px-2.5 py-2 text-xs font-medium text-obs-l-text-muted transition duration-150 hover:border-obs-l-border/50 hover:bg-obs-l-bg-main/60 hover:text-obs-l-text-main dark:text-obs-d-text-muted dark:hover:border-obs-d-border/50 dark:hover:bg-obs-d-bg-main/60 dark:hover:text-obs-d-text-main"
|
||||
[ngClass]="{ 'bg-obs-l-bg-main/80 text-obs-l-text-main shadow-sm dark:bg-obs-d-bg-main/70 dark:text-obs-d-text-main': activeView() === 'calendar' }"
|
||||
[attr.aria-pressed]="activeView() === 'calendar'"
|
||||
aria-label="Afficher l'agenda"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2h-1.5a1.5 1.5 0 01-3 0h-5a1.5 1.5 0 01-3 0H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
<span>Agenda</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
@switch (activeView()) {
|
||||
@case ('files') {
|
||||
<app-file-explorer
|
||||
[nodes]="filteredFileTree()"
|
||||
[selectedNoteId]="selectedNoteId()"
|
||||
(fileSelected)="selectNote($event)"
|
||||
/>
|
||||
}
|
||||
@case ('tags') {
|
||||
<app-tags-view [tags]="filteredTags()" (tagSelected)="handleTagClick($event)" />
|
||||
}
|
||||
@case ('graph') {
|
||||
<div class="h-full p-2"><app-graph-view [graphData]="graphData()" (nodeSelected)="selectNote($event)" /></div>
|
||||
}
|
||||
@case ('search') {
|
||||
<div class="space-y-2 p-3">
|
||||
@if (activeTagFilter(); as tagFilter) {
|
||||
<div class="flex items-center justify-between rounded-lg border border-obs-l-border/60 bg-obs-l-bg-main/70 px-3 py-2 dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/70">
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-obs-l-text-main dark:text-obs-d-text-main">
|
||||
<span>🔖</span>
|
||||
<span class="truncate">#{{ tagFilter }}</span>
|
||||
</div>
|
||||
<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" (click)="clearTagFilter()">Effacer</button>
|
||||
</div>
|
||||
}
|
||||
<h3 class="px-2 py-1 text-sm font-semibold text-obs-l-text-muted dark:text-obs-d-text-muted">{{ searchResults().length }} résultats</h3>
|
||||
<ul class="space-y-1">
|
||||
@for (note of searchResults(); track note.id) {
|
||||
<li
|
||||
(click)="selectNote(note.id)"
|
||||
class="cursor-pointer rounded px-2 py-2 transition hover:bg-obs-l-bg-main dark:hover:bg-obs-d-bg-main"
|
||||
[class.bg-obs-l-bg-main]="selectedNoteId() === note.id"
|
||||
[class.dark:bg-obs-d-bg-main]="selectedNoteId() === note.id"
|
||||
>
|
||||
<div class="truncate font-semibold">{{ note.title }}</div>
|
||||
<div class="truncate text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">
|
||||
{{ note.content.substring(0, 100) }}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@if (calendarSelectionLabel() || calendarSearchState() !== 'idle' || calendarResults().length > 0 || calendarSearchError()) {
|
||||
<div class="mt-4 space-y-2 border-t border-obs-l-border/60 pt-3 dark:border-obs-d-border/60">
|
||||
<div class="flex items-center justify-between px-2">
|
||||
<h3 class="text-sm font-semibold text-obs-l-text-muted dark:text-obs-d-text-muted">Résultats du calendrier</h3>
|
||||
@if (calendarSelectionLabel(); as selectionLabel) {
|
||||
<span class="text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">{{ selectionLabel }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (calendarSearchState() === 'loading') {
|
||||
<div class="px-2 py-2 text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">Recherche en cours...</div>
|
||||
} @else if (calendarSearchError(); as calError) {
|
||||
<div class="px-2 py-2 text-xs text-red-500 dark:text-red-400">{{ calError }}</div>
|
||||
} @else if (calendarResults().length === 0) {
|
||||
<div class="px-2 py-2 text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">Sélectionnez un jour ou une plage dans le calendrier.</div>
|
||||
} @else {
|
||||
<ul class="mt-2 space-y-1">
|
||||
@for (file of calendarResults(); track file.id) {
|
||||
<li>
|
||||
<button
|
||||
(click)="selectNote(file.id)"
|
||||
class="w-full rounded-lg bg-obs-l-bg-main/80 px-2 py-2 text-left transition hover:bg-obs-l-bg-main dark:bg-obs-d-bg-main/60 dark:hover:bg-obs-d-bg-main"
|
||||
>
|
||||
<div class="truncate text-sm font-semibold text-obs-l-text-main dark:text-obs-d-text-main">{{ file.title }}</div>
|
||||
<div class="flex flex-wrap gap-2 text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">
|
||||
<span>Créé : {{ file.createdAt | date:'mediumDate' }}</span>
|
||||
<span>Modifié : {{ file.updatedAt | date:'mediumDate' }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@case ('calendar') {
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="flex items-center justify-between border-b border-obs-l-border/60 bg-obs-l-bg-main/70 px-4 py-3 dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/60">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-obs-l-text-main dark:text-obs-d-text-main">Vue agenda</h3>
|
||||
@if (calendarSelectionLabel(); as selectionLabel) {
|
||||
<p class="mt-1 text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">{{ selectionLabel }}</p>
|
||||
} @else {
|
||||
<p class="mt-1 text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">Sélectionnez un jour ou une plage pour voir les notes correspondantes.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex-1 px-3 py-4">
|
||||
<div
|
||||
class="rounded-2xl border border-obs-l-border/50 bg-obs-l-bg-main/80 p-3 shadow-sm transition duration-200 dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/70"
|
||||
[class.opacity-0]="calendarOverlayVisible()"
|
||||
[class.pointer-events-none]="calendarOverlayVisible()"
|
||||
>
|
||||
<app-markdown-calendar
|
||||
(fileSelected)="selectNote($event)"
|
||||
(searchResultsChange)="onCalendarResultsChange($event)"
|
||||
(searchStateChange)="onCalendarSearchStateChange($event)"
|
||||
(searchErrorChange)="onCalendarSearchErrorChange($event)"
|
||||
(selectionSummaryChange)="onCalendarSelectionSummaryChange($event)"
|
||||
(requestSearchPanel)="onCalendarRequestSearchPanel()"
|
||||
></app-markdown-calendar>
|
||||
</div>
|
||||
@if (calendarOverlayVisible()) {
|
||||
<div class="absolute inset-0 flex flex-col overflow-hidden rounded-2xl border border-obs-l-border/60 bg-obs-l-bg-main/95 shadow-2xl dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/95">
|
||||
<div class="flex items-center justify-between border-b border-obs-l-border/60 px-4 py-3 dark:border-obs-d-border/60">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h4 class="text-sm font-semibold text-obs-l-text-main dark:text-obs-d-text-main">Notes correspondantes</h4>
|
||||
@if (calendarSelectionLabel(); as selectionLabel) {
|
||||
<span class="text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">{{ selectionLabel }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
@if (calendarResults().length > 0) {
|
||||
<span class="text-xs font-medium text-obs-l-text-muted dark:text-obs-d-text-muted">{{ calendarResults().length }} {{ calendarResults().length > 1 ? 'notes' : 'note' }}</span>
|
||||
}
|
||||
<button
|
||||
(click)="clearCalendarResults()"
|
||||
class="rounded-lg border border-transparent p-1.5 text-obs-l-text-muted transition hover:border-obs-l-border/60 hover:bg-obs-l-bg-main/80 hover:text-obs-l-text-main dark:text-obs-d-text-muted dark:hover:border-obs-d-border/60 dark:hover:bg-obs-d-bg-main/70 dark:hover:text-obs-d-text-main"
|
||||
aria-label="Fermer les résultats du calendrier"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 space-y-2 overflow-y-auto px-4 py-4">
|
||||
@if (calendarSearchState() === 'loading') {
|
||||
<div class="rounded-lg bg-obs-l-bg-main/70 px-3 py-3 text-sm text-obs-l-text-muted dark:bg-obs-d-bg-main/70 dark:text-obs-d-text-muted">Recherche en cours...</div>
|
||||
} @else if (calendarSearchError(); as calError) {
|
||||
<div class="rounded-lg border border-red-500/40 bg-red-500/10 px-3 py-3 text-sm text-red-500 dark:border-red-500/30 dark:bg-red-500/15 dark:text-red-400">{{ calError }}</div>
|
||||
} @else if (calendarResults().length === 0) {
|
||||
<div class="rounded-lg bg-obs-l-bg-main/70 px-3 py-3 text-sm text-obs-l-text-muted dark:bg-obs-d-bg-main/70 dark:text-obs-d-text-muted">Sélectionnez une période pour afficher les notes.</div>
|
||||
} @else {
|
||||
<ul class="space-y-2">
|
||||
@for (file of calendarResults(); track file.id) {
|
||||
<li>
|
||||
<button
|
||||
(click)="selectNote(file.id)"
|
||||
class="w-full rounded-xl border border-transparent bg-obs-l-bg-main/85 px-3 py-3 text-left transition hover:border-obs-l-border/60 hover:bg-obs-l-bg-main dark:bg-obs-d-bg-main/70 dark:hover:border-obs-d-border/60 dark:hover:bg-obs-d-bg-main"
|
||||
>
|
||||
<div class="truncate text-sm font-semibold text-obs-l-text-main dark:text-obs-d-text-main">{{ file.title }}</div>
|
||||
<div class="mt-1 flex flex-wrap gap-2 text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">
|
||||
<span>Créé : {{ file.createdAt | date:'mediumDate' }}</span>
|
||||
<span>Modifié : {{ file.updatedAt | date:'mediumDate' }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="border-t border-obs-l-border/60 bg-obs-l-bg-main/60 px-4 py-4 dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/60">
|
||||
@if (calendarOverlayVisible()) {
|
||||
<p class="text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">Fermez le panneau pour revenir au calendrier ou sélectionner une nouvelle période.</p>
|
||||
} @else {
|
||||
<p class="text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">Choisissez une date pour afficher les notes associées.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</aside>
|
||||
}
|
||||
|
||||
@if (!isDesktop() && isSidebarOpen()) {
|
||||
<div
|
||||
class="fixed inset-0 z-30 bg-black/40 transition-opacity duration-200"
|
||||
(click)="closeSidebar()"
|
||||
></div>
|
||||
}
|
||||
|
||||
<div class="hidden lg:flex">
|
||||
<div
|
||||
class="resize-handle h-full"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Redimensionner la barre latérale gauche"
|
||||
(pointerdown)="startLeftResize($event)"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<section class="flex min-w-0 flex-col bg-obs-l-bg-main pb-16 dark:bg-obs-d-bg-main lg:flex-1 lg:min-h-0 lg:overflow-hidden lg:pb-0">
|
||||
<header class="flex flex-col gap-4 border-b border-obs-l-border/60 bg-obs-l-bg-main/95 px-4 py-3 backdrop-blur-xs dark:border-obs-d-border dark:bg-obs-d-bg-main/95 lg:px-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<button
|
||||
class="rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary lg:hidden"
|
||||
(click)="toggleSidebar()"
|
||||
[attr.aria-expanded]="isSidebarOpen()"
|
||||
aria-label="Basculer le menu"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /></svg>
|
||||
</button>
|
||||
<button
|
||||
class="hidden rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary lg:inline-flex"
|
||||
(click)="toggleSidebar()"
|
||||
[attr.aria-expanded]="isSidebarOpen()"
|
||||
aria-label="Afficher ou masquer la barre latérale gauche"
|
||||
>
|
||||
@if (isSidebarOpen()) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/><path d="m16 15-3-3 3-3"/></svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/><path d="m14 9 3 3-3 3"/></svg>
|
||||
}
|
||||
</button>
|
||||
<div class="min-w-0 flex flex-col gap-1">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="inline-flex items-center rounded-lg border border-obs-l-border bg-obs-l-bg-secondary/60 px-2.5 py-1 text-xs font-semibold uppercase tracking-wide text-obs-l-text-muted dark:border-obs-d-border dark:bg-obs-d-bg-secondary/60 dark:text-obs-d-text-muted">{{ vaultName() }}</span>
|
||||
<h1 class="text-lg font-semibold leading-tight text-obs-l-text-main dark:text-obs-d-text-main">ObsiWatcher</h1>
|
||||
</div>
|
||||
@if (selectedNoteBreadcrumb().length > 0) {
|
||||
<p class="truncate text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">{{ selectedNoteBreadcrumb().join(' / ') }}</p>
|
||||
} @else {
|
||||
<p class="truncate text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">Aucune note sélectionnée</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden items-center gap-2 lg:flex">
|
||||
<button
|
||||
(click)="toggleTheme()"
|
||||
class="rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
aria-label="Basculer le thème"
|
||||
>
|
||||
@if (isDarkMode()) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
(click)="toggleOutline()"
|
||||
class="rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
[attr.aria-expanded]="isOutlineOpen()"
|
||||
aria-label="Basculer la barre latérale droite"
|
||||
>
|
||||
@if (isOutlineOpen()) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/><path d="m8 9 3 3-3 3"/></svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/><path d="m10 15-3-3 3-3"/></svg>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between lg:gap-6">
|
||||
<div class="flex items-center gap-2 lg:hidden">
|
||||
<button
|
||||
(click)="toggleTheme()"
|
||||
class="rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
aria-label="Basculer le thème"
|
||||
>
|
||||
@if (isDarkMode()) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
(click)="toggleOutline()"
|
||||
class="rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
[attr.aria-expanded]="isOutlineOpen()"
|
||||
aria-label="Basculer le calendrier"
|
||||
>
|
||||
@if (isOutlineOpen()) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/><path d="m8 9 3 3-3 3"/></svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/><path d="m10 15-3-3 3-3"/></svg>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative w-full max-w-2xl lg:max-w-3xl">
|
||||
<svg class="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 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-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher dans la voûte..."
|
||||
[(ngModel)]="sidebarSearchTerm"
|
||||
(ngModelChange)="activeView.set('search')"
|
||||
class="w-full rounded-full border border-obs-l-border bg-obs-l-bg-secondary/60 pl-11 pr-4 py-2.5 text-sm text-obs-l-text-main placeholder:text-obs-l-text-muted shadow-sm focus:outline-none focus:ring-2 focus:ring-obs-l-accent dark:border-obs-d-border dark:bg-obs-d-bg-secondary/60 dark:text-obs-d-text-main dark:placeholder:text-obs-d-text-muted dark:focus:ring-obs-d-accent"
|
||||
aria-label="Rechercher dans la voûte"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="min-h-0 px-4 py-6 note-content-area lg:flex-1 lg:overflow-y-auto lg:px-8">
|
||||
@if (selectedNote(); as note) {
|
||||
<app-note-viewer
|
||||
[note]="note"
|
||||
[noteHtmlContent]="renderedNoteContent()"
|
||||
(noteLinkClicked)="selectNote($event)"
|
||||
(tagClicked)="handleTagClick($event)"
|
||||
/>
|
||||
} @else {
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<p class="text-obs-l-text-muted dark:text-obs-d-text-muted">Sélectionnez une note pour commencer</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="hidden lg:flex">
|
||||
<div
|
||||
class="resize-handle h-full"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Redimensionner la barre latérale droite"
|
||||
(pointerdown)="startRightResize($event)"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
@if (!isDesktop() && isOutlineOpen()) {
|
||||
<div
|
||||
class="fixed inset-0 z-30 bg-black/40 transition-opacity duration-200"
|
||||
(click)="closeOutlinePanel()"
|
||||
></div>
|
||||
}
|
||||
|
||||
@if (isDesktop() || isOutlineOpen()) {
|
||||
<aside
|
||||
class="fixed inset-x-0 bottom-0 z-40 flex max-h-[80vh] flex-col overflow-hidden border-t border-obs-l-border bg-obs-l-bg-secondary shadow-2xl transition-all duration-200 ease-out dark:border-obs-d-border dark:bg-obs-d-bg-secondary lg:static lg:max-h-none lg:border-l lg:shadow-none"
|
||||
[ngClass]="{
|
||||
'translate-y-0 opacity-100 pointer-events-auto': isOutlineOpen() || isDesktop(),
|
||||
'translate-y-full opacity-0 pointer-events-none': !isOutlineOpen() && !isDesktop()
|
||||
}"
|
||||
[style.width.px]="isDesktop() ? (isOutlineOpen() ? rightSidebarWidth() : 0) : null"
|
||||
[style.minWidth.px]="isDesktop() ? (isOutlineOpen() ? rightSidebarWidth() : 0) : null"
|
||||
[style.maxWidth.px]="isDesktop() ? (isOutlineOpen() ? rightSidebarWidth() : 0) : null"
|
||||
role="complementary"
|
||||
aria-label="Table des matières et calendrier"
|
||||
>
|
||||
<div class="flex flex-col lg:h-full">
|
||||
@if (!isDesktop()) {
|
||||
<div class="px-4 pt-3">
|
||||
<div class="mx-auto mb-3 h-1.5 w-12 rounded-full bg-obs-l-border dark:bg-obs-d-border"></div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold text-obs-l-text-main dark:text-obs-d-text-main">Navigation</h2>
|
||||
<button
|
||||
class="rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-main/70 dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-main/60"
|
||||
(click)="closeOutlinePanel()"
|
||||
aria-label="Fermer le calendrier"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs uppercase tracking-wide text-obs-l-text-muted dark:text-obs-d-text-muted">Table des matières & calendrier</p>
|
||||
</div>
|
||||
}
|
||||
<div class="hidden border-b border-obs-l-border px-4 py-3 text-sm font-semibold uppercase tracking-wide text-obs-l-text-muted dark:border-obs-d-border dark:text-obs-d-text-muted lg:block">
|
||||
Outline
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto px-4 py-4">
|
||||
@if (tableOfContents().length > 0) {
|
||||
<ul class="space-y-2">
|
||||
@for (entry of tableOfContents(); track entry.id) {
|
||||
<li [style.padding-left.rem]="(entry.level - 1) * 0.75" class="flex items-start gap-2 text-sm text-obs-l-text-muted dark:text-obs-d-text-muted">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="mt-1 h-3 w-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" /></svg>
|
||||
<a (click)="scrollToHeading(entry.id)" class="cursor-pointer leading-tight transition hover:text-obs-l-text-main dark:hover:text-obs-d-text-main">
|
||||
{{ entry.text }}
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p class="text-sm italic text-obs-l-text-muted dark:text-obs-d-text-muted">No headings in this note.</p>
|
||||
}
|
||||
</div>
|
||||
<div class="border-t border-obs-l-border bg-obs-l-bg-secondary/60 p-4 dark:border-obs-d-border dark:bg-obs-d-bg-secondary/60">
|
||||
<app-markdown-calendar
|
||||
(fileSelected)="selectNote($event)"
|
||||
(searchResultsChange)="onCalendarResultsChange($event)"
|
||||
(searchStateChange)="onCalendarSearchStateChange($event)"
|
||||
(searchErrorChange)="onCalendarSearchErrorChange($event)"
|
||||
(selectionSummaryChange)="onCalendarSelectionSummaryChange($event)"
|
||||
(requestSearchPanel)="onCalendarRequestSearchPanel()"
|
||||
></app-markdown-calendar>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
}
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<main>
|
||||
<app-file-explorer
|
||||
[nodes]="filteredFileTree()"
|
||||
[selectedNoteId]="selectedNoteId()"
|
||||
(fileSelected)="selectNote($event)"
|
||||
></app-file-explorer>
|
||||
|
||||
<app-tags-view [tags]="filteredTags()" (tagSelected)="handleTagClick($event)"></app-tags-view>
|
||||
|
||||
<app-graph-view [graphData]="graphData()" (nodeSelected)="selectNote($event)"></app-graph-view>
|
||||
|
||||
<app-note-viewer
|
||||
[note]="selectedNote()"
|
||||
[noteHtmlContent]="renderedNoteContent()"
|
||||
(noteLinkClicked)="selectNote($event)"
|
||||
(tagClicked)="handleTagClick($event)"
|
||||
></app-note-viewer>
|
||||
|
||||
<app-markdown-calendar
|
||||
(fileSelected)="selectNote($event)"
|
||||
(searchResultsChange)="onCalendarResultsChange($event)"
|
||||
(searchStateChange)="onCalendarSearchStateChange($event)"
|
||||
(searchErrorChange)="onCalendarSearchErrorChange($event)"
|
||||
(selectionSummaryChange)="onCalendarSelectionSummaryChange($event)"
|
||||
(requestSearchPanel)="onCalendarRequestSearchPanel()"
|
||||
></app-markdown-calendar>
|
||||
</main>
|
||||
@@ -0,0 +1,521 @@
|
||||
<!-- ObsiViewer - Application optimisée pour mobile et desktop -->
|
||||
<main class="relative flex min-h-screen flex-col bg-obs-l-bg-main text-obs-l-text-main dark:bg-obs-d-bg-main dark:text-obs-d-text-main lg:flex-row lg:h-screen lg:overflow-hidden">
|
||||
<!-- Navigation latérale desktop -->
|
||||
<nav class="hidden w-14 flex-col items-center gap-4 border-r border-obs-l-border bg-obs-l-bg-main py-4 dark:border-obs-d-border dark:bg-obs-d-bg-main lg:flex">
|
||||
<button
|
||||
(click)="setView('files')"
|
||||
class="rounded-lg p-2 transition hover:bg-obs-l-bg-secondary dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.bg-obs-l-bg-secondary]="activeView() === 'files'"
|
||||
[class.dark:bg-obs-d-bg-secondary]="activeView() === 'files'"
|
||||
aria-label="Afficher les fichiers"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 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" /></svg>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('search')"
|
||||
class="rounded-lg p-2 transition hover:bg-obs-l-bg-secondary dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.bg-obs-l-bg-secondary]="activeView() === 'search'"
|
||||
[class.dark:bg-obs-d-bg-secondary]="activeView() === 'search'"
|
||||
aria-label="Ouvrir la recherche"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('tags')"
|
||||
class="rounded-lg p-2 transition hover:bg-obs-l-bg-secondary dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.bg-obs-l-bg-secondary]="activeView() === 'tags'"
|
||||
[class.dark:bg-obs-d-bg-secondary]="activeView() === 'tags'"
|
||||
aria-label="Afficher les tags"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 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="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-5 5a2 2 0 01-2.828 0l-7-7A2 2 0 013 8V3z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('graph')"
|
||||
class="rounded-lg p-2 transition hover:bg-obs-l-bg-secondary dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.bg-obs-l-bg-secondary]="activeView() === 'graph'"
|
||||
[class.dark:bg-obs-d-bg-secondary]="activeView() === 'graph'"
|
||||
aria-label="Afficher la vue graphe"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 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="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('calendar')"
|
||||
class="rounded-lg p-2 transition hover:bg-obs-l-bg-secondary dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.bg-obs-l-bg-secondary]="activeView() === 'calendar'"
|
||||
[class.dark:bg-obs-d-bg-secondary]="activeView() === 'calendar'"
|
||||
aria-label="Afficher le calendrier"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 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="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2h-1.5a1.5 1.5 0 01-3 0h-5a1.5 1.5 0 01-3 0H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Navigation mobile -->
|
||||
<nav class="sticky bottom-0 z-30 flex w-full items-center justify-around gap-2 border-t border-obs-l-border bg-obs-l-bg-main/95 px-2 py-2 backdrop-blur-xs dark:border-obs-d-border dark:bg-obs-d-bg-main/95 lg:hidden">
|
||||
<button
|
||||
(click)="setView('files'); toggleSidebarTo(true)"
|
||||
class="flex flex-1 flex-col items-center gap-1 rounded-lg px-3 py-2 text-xs font-semibold text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.text-obs-l-text-main]="activeView() === 'files'"
|
||||
[class.dark:text-obs-d-text-main]="activeView() === 'files'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" 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" /></svg>
|
||||
Fichiers
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('search'); toggleSidebarTo(true)"
|
||||
class="flex flex-1 flex-col items-center gap-1 rounded-lg px-3 py-2 text-xs font-semibold text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.text-obs-l-text-main]="activeView() === 'search'"
|
||||
[class.dark:text-obs-d-text-main]="activeView() === 'search'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||
Recherche
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('tags'); toggleSidebarTo(true)"
|
||||
class="flex flex-1 flex-col items-center gap-1 rounded-lg px-3 py-2 text-xs font-semibold text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.text-obs-l-text-main]="activeView() === 'tags'"
|
||||
[class.dark:text-obs-d-text-main]="activeView() === 'tags'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-5 5a2 2 0 01-2.828 0l-7-7A2 2 0 013 8V3z" /></svg>
|
||||
Tags
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('calendar'); toggleSidebarTo(true)"
|
||||
class="flex flex-1 flex-col items-center gap-1 rounded-lg px-3 py-2 text-xs font-semibold text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
[class.text-obs-l-text-main]="activeView() === 'calendar'"
|
||||
[class.dark:text-obs-d-text-main]="activeView() === 'calendar'"
|
||||
[attr.aria-pressed]="activeView() === 'calendar'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2h-1.5a1.5 1.5 0 01-3 0h-5a1.5 1.5 0 01-3 0H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
Agenda
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@if (isDesktop() || isSidebarOpen()) {
|
||||
<aside
|
||||
class="fixed inset-y-0 left-0 z-40 flex h-screen w-[min(320px,85vw)] -translate-x-full transform flex-col border-r border-obs-l-border bg-obs-l-bg-secondary shadow-xl transition-transform duration-200 ease-in-out dark:border-obs-d-border dark:bg-obs-d-bg-secondary lg:static lg:h-auto lg:w-auto lg:translate-x-0 lg:shadow-none"
|
||||
[class.translate-x-0]="isSidebarOpen() || isDesktop()"
|
||||
[class.pointer-events-none]="!isSidebarOpen() && !isDesktop()"
|
||||
[style.width.px]="isDesktop() ? (isSidebarOpen() ? leftSidebarWidth() : 0) : null"
|
||||
[style.minWidth.px]="isDesktop() ? (isSidebarOpen() ? leftSidebarWidth() : 0) : null"
|
||||
[style.maxWidth.px]="isDesktop() ? (isSidebarOpen() ? leftSidebarWidth() : 0) : null"
|
||||
role="navigation"
|
||||
aria-label="Arborescence de la voûte"
|
||||
>
|
||||
<div class="flex h-full flex-col overflow-hidden"
|
||||
[style.width.px]="isDesktop() ? (isSidebarOpen() ? leftSidebarWidth() : 0) : null"
|
||||
>
|
||||
<div class="space-y-4 border-b border-obs-l-border bg-obs-l-bg-secondary/60 px-4 py-4 dark:border-obs-d-border dark:bg-obs-d-bg-secondary/60">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-2xl border border-obs-l-border/60 bg-obs-l-bg-main/70 text-obs-l-text-muted shadow-sm dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/60 dark:text-obs-d-text-muted">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM5.5 21a6.5 6.5 0 0113 0" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm font-semibold tracking-wide text-obs-l-text-main dark:text-obs-d-text-main">{{ vaultName() }}</span>
|
||||
<div class="mt-1 flex items-center gap-2 text-xs uppercase text-obs-l-text-muted dark:text-obs-d-text-muted">
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-obs-l-bg-main/70 px-2 py-0.5 text-[0.65rem] font-semibold tracking-widest text-obs-l-text-main/80 dark:bg-obs-d-bg-main/60 dark:text-obs-d-text-main/80">{{ activeView() | titlecase }}</span>
|
||||
<span class="hidden text-[0.65rem] tracking-widest text-obs-l-text-muted/80 dark:text-obs-d-text-muted/70 sm:inline">Vue active</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!isDesktop()) {
|
||||
<button
|
||||
class="rounded-xl border border-transparent bg-obs-l-bg-main/70 p-2 text-obs-l-text-muted transition hover:border-obs-l-border/50 hover:bg-obs-l-bg-main/90 dark:bg-obs-d-bg-main/60 dark:text-obs-d-text-muted dark:hover:border-obs-d-border/60 dark:hover:bg-obs-d-bg-main/70"
|
||||
(click)="closeSidebar()"
|
||||
aria-label="Fermer le panneau latéral"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-obs-l-border/60 bg-obs-l-bg-main/75 px-3 py-3 shadow-inner dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/60">
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<button
|
||||
(click)="setView('files')"
|
||||
class="group flex flex-col items-center gap-1 rounded-xl border border-transparent px-2.5 py-2 text-xs font-medium text-obs-l-text-muted transition duration-150 hover:border-obs-l-border/50 hover:bg-obs-l-bg-main/60 hover:text-obs-l-text-main dark:text-obs-d-text-muted dark:hover:border-obs-d-border/50 dark:hover:bg-obs-d-bg-main/60 dark:hover:text-obs-d-text-main"
|
||||
[ngClass]="{ 'bg-obs-l-bg-main/80 text-obs-l-text-main shadow-sm dark:bg-obs-d-bg-main/70 dark:text-obs-d-text-main': activeView() === 'files' }"
|
||||
aria-label="Afficher les fichiers"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" 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" /></svg>
|
||||
<span>Fichiers</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('search')"
|
||||
class="group flex flex-col items-center gap-1 rounded-xl border border-transparent px-2.5 py-2 text-xs font-medium text-obs-l-text-muted transition duration-150 hover:border-obs-l-border/50 hover:bg-obs-l-bg-main/60 hover:text-obs-l-text-main dark:text-obs-d-text-muted dark:hover:border-obs-d-border/50 dark:hover:bg-obs-d-bg-main/60 dark:hover:text-obs-d-text-main"
|
||||
[ngClass]="{ 'bg-obs-l-bg-main/80 text-obs-l-text-main shadow-sm dark:bg-obs-d-bg-main/70 dark:text-obs-d-text-main': activeView() === 'search' }"
|
||||
aria-label="Ouvrir la recherche"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||
<span>Recherche</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('tags')"
|
||||
class="group flex flex-col items-center gap-1 rounded-xl border border-transparent px-2.5 py-2 text-xs font-medium text-obs-l-text-muted transition duration-150 hover:border-obs-l-border/50 hover:bg-obs-l-bg-main/60 hover:text-obs-l-text-main dark:text-obs-d-text-muted dark:hover:border-obs-d-border/50 dark:hover:bg-obs-d-bg-main/60 dark:hover:text-obs-d-text-main"
|
||||
[ngClass]="{ 'bg-obs-l-bg-main/80 text-obs-l-text-main shadow-sm dark:bg-obs-d-bg-main/70 dark:text-obs-d-text-main': activeView() === 'tags' }"
|
||||
aria-label="Afficher les tags"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-5 5a2 2 0 01-2.828 0l-7-7A2 2 0 013 8V3z" /></svg>
|
||||
<span>Tags</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('calendar')"
|
||||
class="group flex flex-col items-center gap-1 rounded-xl border border-transparent px-2.5 py-2 text-xs font-medium text-obs-l-text-muted transition duration-150 hover:border-obs-l-border/50 hover:bg-obs-l-bg-main/60 hover:text-obs-l-text-main dark:text-obs-d-text-muted dark:hover:border-obs-d-border/50 dark:hover:bg-obs-d-bg-main/60 dark:hover:text-obs-d-text-main"
|
||||
[ngClass]="{ 'bg-obs-l-bg-main/80 text-obs-l-text-main shadow-sm dark:bg-obs-d-bg-main/70 dark:text-obs-d-text-main': activeView() === 'calendar' }"
|
||||
[attr.aria-pressed]="activeView() === 'calendar'"
|
||||
aria-label="Afficher l'agenda"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2h-1.5a1.5 1.5 0 01-3 0h-5a1.5 1.5 0 01-3 0H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
<span>Agenda</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
@switch (activeView()) {
|
||||
@case ('files') {
|
||||
<app-file-explorer
|
||||
[nodes]="filteredFileTree()"
|
||||
[selectedNoteId]="selectedNoteId()"
|
||||
(fileSelected)="selectNote($event)"
|
||||
></app-file-explorer>
|
||||
}
|
||||
@case ('tags') {
|
||||
<app-tags-view [tags]="filteredTags()" (tagSelected)="handleTagClick($event)"></app-tags-view>
|
||||
}
|
||||
@case ('graph') {
|
||||
<div class="h-full p-2"><app-graph-view [graphData]="graphData()" (nodeSelected)="selectNote($event)"></app-graph-view></div>
|
||||
}
|
||||
@case ('search') {
|
||||
<div class="space-y-4 p-3">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher dans la voûte..."
|
||||
[ngModel]="sidebarSearchTerm()"
|
||||
(ngModelChange)="updateSearchTerm($event)"
|
||||
class="w-full rounded-xl border border-obs-l-border/60 bg-obs-l-bg-main/80 px-3 py-2.5 text-sm text-obs-l-text-main placeholder:text-obs-l-text-muted shadow-sm focus:outline-none focus:ring-2 focus:ring-obs-l-accent dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/70 dark:text-obs-d-text-main dark:placeholder:text-obs-d-text-muted dark:focus:ring-obs-d-accent"
|
||||
aria-label="Rechercher dans la voûte"
|
||||
/>
|
||||
</div>
|
||||
@if (activeTagFilter(); as tagFilter) {
|
||||
<div class="flex items-center justify-between rounded-lg border border-obs-l-border/60 bg-obs-l-bg-main/75 px-3 py-2 dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/65">
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-obs-l-text-main dark:text-obs-d-text-main">
|
||||
<span>🔖</span>
|
||||
<span class="truncate">#{{ tagFilter }}</span>
|
||||
</div>
|
||||
<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" (click)="clearTagFilter()">Effacer</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between px-2">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wide text-obs-l-text-muted dark:text-obs-d-text-muted">Résultats</h3>
|
||||
<span class="text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">{{ searchResults().length }}</span>
|
||||
</div>
|
||||
@if (searchResults().length > 0) {
|
||||
<ul class="space-y-1">
|
||||
@for (note of searchResults(); track note.id) {
|
||||
<li
|
||||
(click)="selectNote(note.id)"
|
||||
class="cursor-pointer rounded-lg px-3 py-2 transition hover:bg-obs-l-bg-main/70 dark:hover:bg-obs-d-bg-main/60"
|
||||
[class.bg-obs-l-bg-main]="selectedNoteId() === note.id"
|
||||
[class.dark:bg-obs-d-bg-main]="selectedNoteId() === note.id"
|
||||
>
|
||||
<div class="truncate text-sm font-semibold text-obs-l-text-main dark:text-obs-d-text-main">{{ note.title }}</div>
|
||||
<div class="truncate text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">{{ note.content.substring(0, 100) }}</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p class="rounded-lg border border-dashed border-obs-l-border/60 px-3 py-3 text-sm text-obs-l-text-muted dark:border-obs-d-border/60 dark:text-obs-d-text-muted">Aucun résultat pour cette recherche.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (calendarSelectionLabel() || calendarSearchState() !== 'idle' || calendarResults().length > 0 || calendarSearchError()) {
|
||||
<div class="space-y-3 rounded-xl border border-obs-l-border/60 bg-obs-l-bg-main/75 p-3 shadow-inner dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/65">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-obs-l-text-main dark:text-obs-d-text-main">Résultats du calendrier</h3>
|
||||
@if (calendarSelectionLabel(); as selectionLabel) {
|
||||
<p class="text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">{{ selectionLabel }}</p>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
class="rounded-lg border border-transparent px-2 py-1 text-xs text-obs-l-text-muted transition hover:border-obs-l-border/60 hover:bg-obs-l-bg-main/80 hover:text-obs-l-text-main dark:text-obs-d-text-muted dark:hover:border-obs-d-border/60 dark:hover:bg-obs-d-bg-main/70 dark:hover:text-obs-d-text-main"
|
||||
(click)="clearCalendarResults()"
|
||||
aria-label="Effacer les résultats du calendrier"
|
||||
>
|
||||
Effacer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (calendarSearchState() === 'loading') {
|
||||
<div class="rounded-lg bg-obs-l-bg-main/80 px-3 py-2 text-xs text-obs-l-text-muted dark:bg-obs-d-bg-main/70 dark:text-obs-d-text-muted">Recherche en cours...</div>
|
||||
} @else if (calendarSearchError(); as calError) {
|
||||
<div class="rounded-lg border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs text-red-500 dark:border-red-500/30 dark:bg-red-500/15 dark:text-red-400">{{ calError }}</div>
|
||||
} @else if (calendarResults().length === 0) {
|
||||
<div class="rounded-lg bg-obs-l-bg-main/80 px-3 py-2 text-xs text-obs-l-text-muted dark:bg-obs-d-bg-main/70 dark:text-obs-d-text-muted">Sélectionnez une date dans le calendrier pour voir les notes correspondantes.</div>
|
||||
} @else {
|
||||
<ul class="space-y-2">
|
||||
@for (file of calendarResults(); track file.id) {
|
||||
<li>
|
||||
<button
|
||||
(click)="selectNote(file.id)"
|
||||
class="w-full rounded-lg border border-transparent bg-obs-l-bg-main/85 px-3 py-2 text-left transition hover:border-obs-l-border/60 hover:bg-obs-l-bg-main dark:bg-obs-d-bg-main/70 dark:hover:border-obs-d-border/60 dark:hover:bg-obs-d-bg-main"
|
||||
>
|
||||
<div class="truncate text-sm font-semibold text-obs-l-text-main dark:text-obs-d-text-main">{{ file.title }}</div>
|
||||
<div class="mt-1 flex flex-wrap gap-2 text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">
|
||||
<span>Créé : {{ file.createdAt | date:'mediumDate' }}</span>
|
||||
<span>Modifié : {{ file.updatedAt | date:'mediumDate' }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@case ('calendar') {
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="flex items-center justify-between border-b border-obs-l-border/60 bg-obs-l-bg-main/70 px-4 py-3 dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/60">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-obs-l-text-main dark:text-obs-d-text-main">Vue agenda</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto px-3 py-4">
|
||||
<app-markdown-calendar
|
||||
(fileSelected)="selectNote($event)"
|
||||
(searchResultsChange)="onCalendarResultsChange($event)"
|
||||
(searchStateChange)="onCalendarSearchStateChange($event)"
|
||||
(searchErrorChange)="onCalendarSearchErrorChange($event)"
|
||||
(selectionSummaryChange)="onCalendarSelectionSummaryChange($event)"
|
||||
(requestSearchPanel)="onCalendarRequestSearchPanel()"
|
||||
></app-markdown-calendar>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
}
|
||||
|
||||
@if (!isDesktop() && isSidebarOpen()) {
|
||||
<div
|
||||
class="fixed inset-0 z-30 bg-black/40 transition-opacity duration-200"
|
||||
(click)="closeSidebar()"
|
||||
></div>
|
||||
}
|
||||
|
||||
<div class="hidden lg:flex">
|
||||
<div
|
||||
class="resize-handle h-full"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Redimensionner la barre latérale gauche"
|
||||
(pointerdown)="startLeftResize($event)"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<section class="flex min-w-0 flex-col bg-obs-l-bg-main pb-16 dark:bg-obs-d-bg-main lg:flex-1 lg:min-h-0 lg:overflow-hidden lg:pb-0">
|
||||
<header class="flex flex-col gap-4 border-b border-obs-l-border/60 bg-obs-l-bg-main/95 px-4 py-3 backdrop-blur-xs dark:border-obs-d-border dark:bg-obs-d-bg-main/95 lg:px-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<button
|
||||
class="rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary lg:hidden"
|
||||
(click)="toggleSidebar()"
|
||||
[attr.aria-expanded]="isSidebarOpen()"
|
||||
aria-label="Basculer le menu"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /></svg>
|
||||
</button>
|
||||
<button
|
||||
class="hidden rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary lg:inline-flex"
|
||||
(click)="toggleSidebar()"
|
||||
[attr.aria-expanded]="isSidebarOpen()"
|
||||
aria-label="Afficher ou masquer la barre latérale gauche"
|
||||
>
|
||||
@if (isSidebarOpen()) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/><path d="m16 15-3-3 3-3"/></svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/><path d="m14 9 3 3-3 3"/></svg>
|
||||
}
|
||||
</button>
|
||||
<div class="min-w-0 flex flex-col gap-1">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="inline-flex items-center rounded-lg border border-obs-l-border bg-obs-l-bg-secondary/60 px-2.5 py-1 text-xs font-semibold uppercase tracking-wide text-obs-l-text-muted dark:border-obs-d-border dark:bg-obs-d-bg-secondary/60 dark:text-obs-d-text-muted">{{ vaultName() }}</span>
|
||||
<h1 class="text-lg font-semibold leading-tight text-obs-l-text-main dark:text-obs-d-text-main">ObsiWatcher</h1>
|
||||
</div>
|
||||
@if (selectedNoteBreadcrumb().length > 0) {
|
||||
<p class="truncate text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">{{ selectedNoteBreadcrumb().join(' / ') }}</p>
|
||||
} @else {
|
||||
<p class="truncate text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">Aucune note sélectionnée</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden items-center gap-2 lg:flex">
|
||||
<button
|
||||
(click)="toggleTheme()"
|
||||
class="rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
aria-label="Basculer le thème"
|
||||
>
|
||||
@if (isDarkMode()) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
(click)="toggleOutline()"
|
||||
class="rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
[attr.aria-expanded]="isOutlineOpen()"
|
||||
aria-label="Basculer la barre latérale droite"
|
||||
>
|
||||
@if (isOutlineOpen()) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/><path d="m8 9 3 3-3 3"/></svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/><path d="m10 15-3-3 3-3"/></svg>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between lg:gap-6">
|
||||
<div class="flex items-center gap-2 lg:hidden">
|
||||
<button
|
||||
(click)="toggleTheme()"
|
||||
class="rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
aria-label="Basculer le thème"
|
||||
>
|
||||
@if (isDarkMode()) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
(click)="toggleOutline()"
|
||||
class="rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
[attr.aria-expanded]="isOutlineOpen()"
|
||||
aria-label="Basculer la barre latérale droite"
|
||||
>
|
||||
@if (isOutlineOpen()) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/><path d="m8 9 3 3-3 3"/></svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/><path d="m10 15-3-3 3-3"/></svg>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-1 items-center gap-3">
|
||||
<div class="relative w-full max-w-2xl lg:max-w-3xl">
|
||||
<svg class="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 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-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher dans la voûte..."
|
||||
[ngModel]="sidebarSearchTerm()"
|
||||
(ngModelChange)="updateSearchTerm($event, true)"
|
||||
class="w-full rounded-full border border-obs-l-border bg-obs-l-bg-secondary/60 pl-11 pr-4 py-2.5 text-sm text-obs-l-text-main placeholder:text-obs-l-text-muted shadow-sm focus:outline-none focus:ring-2 focus:ring-obs-l-accent dark:border-obs-d-border dark:bg-obs-d-bg-secondary/60 dark:text-obs-d-text-main dark:placeholder:text-obs-d-text-muted dark:focus:ring-obs-d-accent"
|
||||
aria-label="Rechercher dans la voûte"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="hidden rounded-full border border-obs-l-border px-3 py-2 text-xs font-semibold uppercase tracking-wide text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:border-obs-d-border dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary lg:inline-flex"
|
||||
(click)="toggleOutline()"
|
||||
[attr.aria-pressed]="isOutlineOpen()"
|
||||
>
|
||||
Outline
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="min-h-0 px-4 py-6 note-content-area lg:flex-1 lg:overflow-y-auto lg:px-8">
|
||||
@if (selectedNote(); as note) {
|
||||
<app-note-viewer
|
||||
[note]="note"
|
||||
[noteHtmlContent]="renderedNoteContent()"
|
||||
(noteLinkClicked)="selectNote($event)"
|
||||
(tagClicked)="handleTagClick($event)"
|
||||
></app-note-viewer>
|
||||
} @else {
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<p class="text-obs-l-text-muted dark:text-obs-d-text-muted">Sélectionnez une note pour commencer</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="hidden lg:flex">
|
||||
<div
|
||||
class="resize-handle h-full"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Redimensionner la barre latérale droite"
|
||||
(pointerdown)="startRightResize($event)"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
@if (!isDesktop() && isOutlineOpen()) {
|
||||
<div
|
||||
class="fixed inset-0 z-30 bg-black/40 transition-opacity duration-200"
|
||||
(click)="closeOutlinePanel()"
|
||||
></div>
|
||||
}
|
||||
|
||||
@if (isDesktop() || isOutlineOpen()) {
|
||||
<aside
|
||||
class="fixed inset-x-0 bottom-0 z-40 flex max-h-[80vh] flex-col overflow-hidden border-t border-obs-l-border bg-obs-l-bg-secondary shadow-2xl transition-all duration-200 ease-out dark:border-obs-d-border dark:bg-obs-d-bg-secondary lg:static lg:max-h-none lg:border-l lg:shadow-none"
|
||||
[ngClass]="{
|
||||
'translate-y-0 opacity-100 pointer-events-auto': isOutlineOpen() || isDesktop(),
|
||||
'translate-y-full opacity-0 pointer-events-none': !isOutlineOpen() && !isDesktop()
|
||||
}"
|
||||
[style.width.px]="isDesktop() ? (isOutlineOpen() ? rightSidebarWidth() : 0) : null"
|
||||
[style.minWidth.px]="isDesktop() ? (isOutlineOpen() ? rightSidebarWidth() : 0) : null"
|
||||
[style.maxWidth.px]="isDesktop() ? (isOutlineOpen() ? rightSidebarWidth() : 0) : null"
|
||||
role="complementary"
|
||||
aria-label="Table des matières et calendrier"
|
||||
>
|
||||
<div class="flex flex-col lg:h-full">
|
||||
@if (!isDesktop()) {
|
||||
<div class="px-4 pt-3">
|
||||
<div class="mx-auto mb-3 h-1.5 w-12 rounded-full bg-obs-l-border dark:bg-obs-d-border"></div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold text-obs-l-text-main dark:text-obs-d-text-main">Navigation</h2>
|
||||
<button
|
||||
class="rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-main/70 dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-main/60"
|
||||
(click)="closeOutlinePanel()"
|
||||
aria-label="Fermer la barre latérale droite"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs uppercase tracking-wide text-obs-l-text-muted dark:text-obs-d-text-muted">Table des matières & calendrier</p>
|
||||
</div>
|
||||
}
|
||||
<div class="hidden border-b border-obs-l-border px-4 py-3 text-sm font-semibold uppercase tracking-wide text-obs-l-text-muted dark:border-obs-d-border dark:text-obs-d-text-muted lg:block">
|
||||
Outline
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto px-4 py-4">
|
||||
@if (tableOfContents().length > 0) {
|
||||
<ul class="space-y-2">
|
||||
@for (entry of tableOfContents(); track entry.id) {
|
||||
<li [style.padding-left.rem]="(entry.level - 1) * 0.75" class="flex items-start gap-2 text-sm text-obs-l-text-muted dark:text-obs-d-text-muted">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="mt-1 h-3 w-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" /></svg>
|
||||
<a (click)="scrollToHeading(entry.id)" class="cursor-pointer leading-tight transition hover:text-obs-l-text-main dark:hover:text-obs-d-text-main">
|
||||
{{ entry.text }}
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p class="text-sm italic text-obs-l-text-muted dark:text-obs-d-text-muted">Aucun titre dans cette note.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
}
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, ElementRef, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
// Services
|
||||
import { VaultService } from './services/vault.service';
|
||||
import { MarkdownService } from './services/markdown.service';
|
||||
|
||||
// Components
|
||||
import { FileExplorerComponent } from './components/file-explorer/file-explorer.component';
|
||||
import { NoteViewerComponent } from './components/note-viewer/note-viewer.component';
|
||||
import { GraphViewComponent } from './components/graph-view/graph-view.component';
|
||||
import { TagsViewComponent } from './components/tags-view/tags-view.component';
|
||||
import { MarkdownCalendarComponent } from './components/markdown-calendar/markdown-calendar.component';
|
||||
|
||||
// Types
|
||||
import { FileMetadata, Note, TagInfo, VaultNode } from './types';
|
||||
|
||||
interface TocEntry {
|
||||
level: number;
|
||||
text: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
FileExplorerComponent,
|
||||
NoteViewerComponent,
|
||||
GraphViewComponent,
|
||||
TagsViewComponent,
|
||||
MarkdownCalendarComponent,
|
||||
],
|
||||
templateUrl: './app.component.simple.html',
|
||||
styleUrls: ['./app.component.css'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AppComponent implements OnDestroy {
|
||||
private vaultService = inject(VaultService);
|
||||
private markdownService = inject(MarkdownService);
|
||||
private elementRef = inject(ElementRef);
|
||||
|
||||
// --- State Signals ---
|
||||
isDarkMode = signal<boolean>(true);
|
||||
isSidebarOpen = signal<boolean>(true);
|
||||
isOutlineOpen = signal<boolean>(true);
|
||||
activeView = signal<'files' | 'graph' | 'tags' | 'search' | 'calendar'>('files');
|
||||
selectedNoteId = signal<string>('');
|
||||
sidebarSearchTerm = signal<string>('');
|
||||
tableOfContents = signal<TocEntry[]>([]);
|
||||
leftSidebarWidth = signal<number>(288);
|
||||
rightSidebarWidth = signal<number>(288);
|
||||
readonly LEFT_MIN_WIDTH = 220;
|
||||
readonly LEFT_MAX_WIDTH = 520;
|
||||
readonly RIGHT_MIN_WIDTH = 220;
|
||||
readonly RIGHT_MAX_WIDTH = 520;
|
||||
|
||||
private viewportWidth = signal<number>(typeof window !== 'undefined' ? window.innerWidth : 0);
|
||||
private resizeHandler = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
this.viewportWidth.set(window.innerWidth);
|
||||
};
|
||||
|
||||
isDesktopView = computed<boolean>(() => this.viewportWidth() >= 1024);
|
||||
private wasDesktop = false;
|
||||
|
||||
calendarResults = signal<FileMetadata[]>([]);
|
||||
calendarSearchState = signal<'idle' | 'loading' | 'error'>('idle');
|
||||
calendarSearchError = signal<string | null>(null);
|
||||
calendarSelectionLabel = signal<string | null>(null);
|
||||
calendarOverlayVisible = computed<boolean>(() =>
|
||||
this.calendarSearchState() === 'loading' ||
|
||||
!!this.calendarSearchError() ||
|
||||
this.calendarResults().length > 0
|
||||
);
|
||||
private calendarSearchTriggered = false;
|
||||
|
||||
// --- Data Signals ---
|
||||
fileTree = this.vaultService.fileTree;
|
||||
graphData = this.vaultService.graphData;
|
||||
allTags = this.vaultService.tags;
|
||||
vaultName = this.vaultService.vaultName;
|
||||
|
||||
// --- Computed Signals ---
|
||||
selectedNote = computed<Note | undefined>(() => {
|
||||
const id = this.selectedNoteId();
|
||||
return id ? this.vaultService.getNoteById(id) : undefined;
|
||||
});
|
||||
|
||||
renderedNoteContent = computed<string>(() => {
|
||||
const note = this.selectedNote();
|
||||
if (!note) return '';
|
||||
const allNotes = this.vaultService.allNotes();
|
||||
return this.markdownService.render(note.content, allNotes);
|
||||
});
|
||||
|
||||
selectedNoteBreadcrumb = computed<string[]>(() => {
|
||||
const note = this.selectedNote();
|
||||
if (!note) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const vaultTitle = this.vaultName().trim();
|
||||
const breadcrumb: string[] = [vaultTitle || 'Vault'];
|
||||
const pathSegments = note.originalPath.split('/').filter(Boolean);
|
||||
|
||||
if (pathSegments.length === 0) {
|
||||
breadcrumb.push(note.fileName.replace(/\.md$/i, ''));
|
||||
return breadcrumb;
|
||||
}
|
||||
|
||||
const displaySegments = [...pathSegments];
|
||||
displaySegments[displaySegments.length - 1] = note.fileName.replace(/\.md$/i, '');
|
||||
return breadcrumb.concat(displaySegments);
|
||||
});
|
||||
|
||||
filteredTags = computed<TagInfo[]>(() => {
|
||||
const term = this.sidebarSearchTerm().trim().toLowerCase();
|
||||
const cleanedTerm = term.startsWith('#') ? term.slice(1) : term;
|
||||
if (!cleanedTerm) return this.allTags();
|
||||
return this.allTags().filter(tag => tag.name.toLowerCase().includes(cleanedTerm));
|
||||
});
|
||||
|
||||
filteredFileTree = computed<VaultNode[]>(() => {
|
||||
const term = this.sidebarSearchTerm().trim().toLowerCase();
|
||||
if (!term || term.startsWith('#')) return this.fileTree();
|
||||
// Simple flat search for files
|
||||
return this.fileTree().filter(node => node.type === 'file' && node.name.toLowerCase().includes(term));
|
||||
});
|
||||
|
||||
activeTagFilter = computed<string | null>(() => {
|
||||
const rawTerm = this.sidebarSearchTerm().trim();
|
||||
if (!rawTerm.startsWith('#')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tag = rawTerm.slice(1).trim();
|
||||
return tag ? tag.toLowerCase() : null;
|
||||
});
|
||||
|
||||
searchResults = computed<Note[]>(() => {
|
||||
const notes = this.vaultService.allNotes();
|
||||
const tagFilter = this.activeTagFilter();
|
||||
|
||||
if (tagFilter) {
|
||||
return notes.filter(note => note.tags.some(tag => tag.toLowerCase() === tagFilter));
|
||||
}
|
||||
|
||||
const term = this.sidebarSearchTerm().trim().toLowerCase();
|
||||
if (!term) return [];
|
||||
const cleanedTerm = term.startsWith('#') ? term.slice(1) : term;
|
||||
return notes.filter(note =>
|
||||
note.title.toLowerCase().includes(cleanedTerm) ||
|
||||
note.content.toLowerCase().includes(cleanedTerm) ||
|
||||
note.tags.some(tag => tag.toLowerCase().includes(cleanedTerm))
|
||||
);
|
||||
});
|
||||
|
||||
constructor() {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('resize', this.resizeHandler, { passive: true });
|
||||
}
|
||||
|
||||
this.wasDesktop = this.isDesktopView();
|
||||
if (!this.isDesktopView()) {
|
||||
this.isSidebarOpen.set(false);
|
||||
this.isOutlineOpen.set(false);
|
||||
}
|
||||
|
||||
// Effect to update the DOM with the dark class
|
||||
effect(() => {
|
||||
if (this.isDarkMode()) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const isDesktop = this.isDesktopView();
|
||||
if (isDesktop && !this.wasDesktop) {
|
||||
this.isSidebarOpen.set(true);
|
||||
this.isOutlineOpen.set(true);
|
||||
}
|
||||
if (!isDesktop && this.wasDesktop) {
|
||||
this.isSidebarOpen.set(false);
|
||||
this.isOutlineOpen.set(false);
|
||||
}
|
||||
this.wasDesktop = isDesktop;
|
||||
});
|
||||
|
||||
// Effect to generate Table of Contents when the note changes
|
||||
effect(() => {
|
||||
const html = this.renderedNoteContent();
|
||||
if (html && this.selectedNote()) {
|
||||
this.generateToc(html);
|
||||
} else {
|
||||
this.tableOfContents.set([]);
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const vaultTitle = this.vaultName().trim();
|
||||
document.title = `ObsiWatcher - ${vaultTitle || 'Vault'}`;
|
||||
});
|
||||
|
||||
// Effect to select first available note when vault data loads
|
||||
effect(() => {
|
||||
const notes = this.vaultService.allNotes();
|
||||
const currentId = this.selectedNoteId();
|
||||
if (!notes.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentExists = notes.some(note => note.id === currentId);
|
||||
if (!currentExists) {
|
||||
const firstNote = notes[0];
|
||||
this.vaultService.ensureFolderOpen(firstNote.originalPath);
|
||||
this.selectedNoteId.set(firstNote.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('resize', this.resizeHandler);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Methods ---
|
||||
toggleTheme(): void {
|
||||
this.isDarkMode.update(value => !value);
|
||||
}
|
||||
|
||||
toggleSidebar(): void {
|
||||
this.isSidebarOpen.update(value => !value);
|
||||
}
|
||||
|
||||
closeSidebar(): void {
|
||||
this.isSidebarOpen.set(false);
|
||||
}
|
||||
|
||||
toggleSidebarTo(state: boolean): void {
|
||||
this.isSidebarOpen.set(state);
|
||||
}
|
||||
|
||||
toggleOutline(): void {
|
||||
this.isOutlineOpen.update(value => !value);
|
||||
}
|
||||
|
||||
closeOutlinePanel(): void {
|
||||
this.isOutlineOpen.set(false);
|
||||
}
|
||||
|
||||
toggleOutlineTo(state: boolean): void {
|
||||
this.isOutlineOpen.set(state);
|
||||
}
|
||||
|
||||
isDesktop(): boolean {
|
||||
return this.isDesktopView();
|
||||
}
|
||||
|
||||
onCalendarResultsChange(files: FileMetadata[]): void {
|
||||
this.calendarResults.set(files);
|
||||
if (this.calendarSearchTriggered || files.length > 0 || this.activeView() === 'search') {
|
||||
this.isSidebarOpen.set(true);
|
||||
this.activeView.set('search');
|
||||
this.calendarSearchTriggered = false;
|
||||
}
|
||||
}
|
||||
|
||||
onCalendarSearchStateChange(state: 'idle' | 'loading' | 'error'): void {
|
||||
this.calendarSearchState.set(state);
|
||||
if (state === 'loading') {
|
||||
this.calendarSearchTriggered = true;
|
||||
}
|
||||
}
|
||||
|
||||
onCalendarSearchErrorChange(message: string | null): void {
|
||||
this.calendarSearchError.set(message);
|
||||
if (message) {
|
||||
this.isSidebarOpen.set(true);
|
||||
this.activeView.set('search');
|
||||
this.calendarSearchTriggered = false;
|
||||
}
|
||||
}
|
||||
|
||||
onCalendarSelectionSummaryChange(summary: string | null): void {
|
||||
this.calendarSelectionLabel.set(summary);
|
||||
}
|
||||
|
||||
onCalendarRequestSearchPanel(): void {
|
||||
if (this.activeView() === 'calendar') {
|
||||
if (!this.isSidebarOpen()) {
|
||||
this.isSidebarOpen.set(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.isSidebarOpen.set(true);
|
||||
this.activeView.set('search');
|
||||
}
|
||||
|
||||
startLeftResize(event: PointerEvent): void {
|
||||
if (!this.isSidebarOpen()) {
|
||||
this.isSidebarOpen.set(true);
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const handle = event.currentTarget as HTMLElement | null;
|
||||
handle?.setPointerCapture(event.pointerId);
|
||||
|
||||
const startX = event.clientX;
|
||||
const startWidth = this.leftSidebarWidth();
|
||||
|
||||
const moveHandler = (moveEvent: PointerEvent) => {
|
||||
const delta = moveEvent.clientX - startX;
|
||||
let newWidth = startWidth + delta;
|
||||
newWidth = Math.max(this.LEFT_MIN_WIDTH, Math.min(this.LEFT_MAX_WIDTH, newWidth));
|
||||
this.leftSidebarWidth.set(newWidth);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('pointermove', moveHandler);
|
||||
window.removeEventListener('pointerup', cleanup);
|
||||
window.removeEventListener('pointercancel', cleanup);
|
||||
if (handle && handle.hasPointerCapture?.(event.pointerId)) {
|
||||
handle.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
handle?.removeEventListener('lostpointercapture', cleanup);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', moveHandler);
|
||||
window.addEventListener('pointerup', cleanup);
|
||||
window.addEventListener('pointercancel', cleanup);
|
||||
handle?.addEventListener('lostpointercapture', cleanup);
|
||||
}
|
||||
|
||||
startRightResize(event: PointerEvent): void {
|
||||
if (!this.isOutlineOpen()) {
|
||||
this.isOutlineOpen.set(true);
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const handle = event.currentTarget as HTMLElement | null;
|
||||
handle?.setPointerCapture(event.pointerId);
|
||||
|
||||
const startX = event.clientX;
|
||||
const startWidth = this.rightSidebarWidth();
|
||||
|
||||
const moveHandler = (moveEvent: PointerEvent) => {
|
||||
const delta = moveEvent.clientX - startX;
|
||||
let newWidth = startWidth - delta;
|
||||
newWidth = Math.max(this.RIGHT_MIN_WIDTH, Math.min(this.RIGHT_MAX_WIDTH, newWidth));
|
||||
this.rightSidebarWidth.set(newWidth);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('pointermove', moveHandler);
|
||||
window.removeEventListener('pointerup', cleanup);
|
||||
window.removeEventListener('pointercancel', cleanup);
|
||||
if (handle && handle.hasPointerCapture?.(event.pointerId)) {
|
||||
handle.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
handle?.removeEventListener('lostpointercapture', cleanup);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', moveHandler);
|
||||
window.addEventListener('pointerup', cleanup);
|
||||
window.addEventListener('pointercancel', cleanup);
|
||||
handle?.addEventListener('lostpointercapture', cleanup);
|
||||
}
|
||||
|
||||
setView(view: 'files' | 'graph' | 'tags' | 'search' | 'calendar'): void {
|
||||
this.activeView.set(view);
|
||||
this.sidebarSearchTerm.set('');
|
||||
}
|
||||
|
||||
selectNote(noteId: string): void {
|
||||
const note = this.vaultService.getNoteById(noteId);
|
||||
if (!note) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.vaultService.ensureFolderOpen(note.originalPath);
|
||||
this.selectedNoteId.set(note.id);
|
||||
}
|
||||
|
||||
handleTagClick(tagName: string): void {
|
||||
const normalized = tagName.replace(/^#/, '').trim();
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
this.setView('search');
|
||||
this.sidebarSearchTerm.set(`#${normalized}`);
|
||||
}
|
||||
|
||||
clearTagFilter(): void {
|
||||
this.sidebarSearchTerm.set('');
|
||||
}
|
||||
|
||||
clearCalendarResults(): void {
|
||||
this.calendarResults.set([]);
|
||||
this.calendarSearchState.set('idle');
|
||||
this.calendarSearchError.set(null);
|
||||
this.calendarSelectionLabel.set(null);
|
||||
this.calendarSearchTriggered = false;
|
||||
}
|
||||
|
||||
updateSearchTerm(term: string, focusSearch = false): void {
|
||||
this.sidebarSearchTerm.set(term ?? '');
|
||||
if (focusSearch || (term && term.trim().length > 0)) {
|
||||
this.activeView.set('search');
|
||||
}
|
||||
}
|
||||
|
||||
private generateToc(html: string): void {
|
||||
const toc: TocEntry[] = [];
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||
|
||||
headings.forEach(heading => {
|
||||
if (heading.id && heading.textContent) {
|
||||
toc.push({
|
||||
level: parseInt(heading.tagName.substring(1), 10),
|
||||
text: heading.textContent,
|
||||
id: heading.id
|
||||
});
|
||||
}
|
||||
});
|
||||
this.tableOfContents.set(toc);
|
||||
}
|
||||
|
||||
scrollToHeading(id: string): void {
|
||||
// The note viewer component's content area is what scrolls
|
||||
const contentArea = (this.elementRef.nativeElement as HTMLElement).querySelector('.note-content-area');
|
||||
if(contentArea) {
|
||||
const element = contentArea.querySelector(`#${id}`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { FileMetadata } from '../types';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class MarkdownCalendarService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
getFilesMetadata(): Observable<FileMetadata[]> {
|
||||
return this.http.get<FileMetadata[]>('/api/files/metadata');
|
||||
}
|
||||
|
||||
searchFilesByDate(date: Date): Observable<FileMetadata[]> {
|
||||
return this.http.get<FileMetadata[]>('/api/files/by-date', {
|
||||
params: { date: date.toISOString() },
|
||||
});
|
||||
}
|
||||
|
||||
searchFilesByDateRange(startDate: Date, endDate: Date): Observable<FileMetadata[]> {
|
||||
return this.http.get<FileMetadata[]>('/api/files/by-date-range', {
|
||||
params: {
|
||||
start: startDate.toISOString(),
|
||||
end: endDate.toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
groupMetadataByDay(metadata: FileMetadata[]): Map<string, { created: FileMetadata[]; updated: FileMetadata[] }> {
|
||||
const map = new Map<string, { created: FileMetadata[]; updated: FileMetadata[] }>();
|
||||
|
||||
for (const file of metadata) {
|
||||
const createdKey = this.normalizeDateKey(file.createdAt);
|
||||
const updatedKey = this.normalizeDateKey(file.updatedAt);
|
||||
|
||||
if (createdKey) {
|
||||
const bucket = map.get(createdKey) ?? { created: [], updated: [] };
|
||||
bucket.created = this.appendUnique(bucket.created, file);
|
||||
map.set(createdKey, bucket);
|
||||
}
|
||||
|
||||
if (updatedKey) {
|
||||
const bucket = map.get(updatedKey) ?? { created: [], updated: [] };
|
||||
bucket.updated = this.appendUnique(bucket.updated, file);
|
||||
map.set(updatedKey, bucket);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private normalizeDateKey(value: string | undefined | null): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date.toDateString();
|
||||
}
|
||||
|
||||
private appendUnique(list: FileMetadata[], file: FileMetadata): FileMetadata[] {
|
||||
return list.some((item) => item.id === file.id) ? list : list.concat(file);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import hljs from 'highlight.js';
|
||||
import { Note } from '../types';
|
||||
|
||||
type TableAlignment = 'left' | 'center' | 'right' | null;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MarkdownService {
|
||||
|
||||
private readonly tagPaletteSize = 12;
|
||||
private tagColorCache = new Map<string, number>();
|
||||
|
||||
render(markdown: string, allNotes: Note[]): string {
|
||||
let html = markdown;
|
||||
|
||||
// Process code blocks first to prevent inner content from being parsed
|
||||
const codeBlocks = new Map<string, { html: string; language: string }>();
|
||||
html = html.replace(/```([^\n]*)\n([\s\S]*?)```/g, (match, lang, code) => {
|
||||
const id = `___CODEBLOCK_${codeBlocks.size}___`;
|
||||
const rawLanguage = (lang ?? '').trim();
|
||||
const normalizedLanguage = rawLanguage.toLowerCase();
|
||||
const isMermaid = normalizedLanguage === 'mermaid';
|
||||
let highlightedCode = '';
|
||||
|
||||
if (!isMermaid) {
|
||||
if (normalizedLanguage && hljs.getLanguage(normalizedLanguage)) {
|
||||
highlightedCode = hljs.highlight(code, { language: normalizedLanguage }).value;
|
||||
} else if (normalizedLanguage) {
|
||||
highlightedCode = hljs.highlightAuto(code).value;
|
||||
} else {
|
||||
highlightedCode = hljs.highlightAuto(code).value;
|
||||
}
|
||||
}
|
||||
|
||||
const languageLabel = rawLanguage || 'plaintext';
|
||||
const languageDisplay = rawLanguage ? rawLanguage : 'Plain text';
|
||||
const encodedRawCode = encodeURIComponent(code);
|
||||
const safeLanguageDisplay = this.escapeHtml(languageDisplay);
|
||||
const headerHtml = `
|
||||
<div class="code-block__header">
|
||||
<div class="code-block__controls" aria-hidden="true">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
<div class="code-block__actions">
|
||||
<button type="button" class="code-block__language-badge" data-language="${languageLabel.toLowerCase()}" data-code-id="${id}">${safeLanguageDisplay}</button>
|
||||
<span class="code-block__copy-feedback" hidden></span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const codeHtml = isMermaid
|
||||
? `
|
||||
<div class="code-block__body mermaid-block__body">
|
||||
<div class="mermaid-diagram" data-mermaid-code="${encodedRawCode}"></div>
|
||||
<pre class="mermaid-source" hidden><code class="hljs" data-raw-code="${encodedRawCode}">${this.escapeHtml(code)}</code></pre>
|
||||
</div>
|
||||
`
|
||||
: `<pre class="code-block__body"><code class="hljs" data-raw-code="${encodedRawCode}">${highlightedCode}</code></pre>`;
|
||||
const wrapperClass = isMermaid ? 'code-block code-block--mermaid text-left' : 'code-block text-left';
|
||||
const wrapper = `<div class="${wrapperClass}" style="max-width: 800px; width: 100%; margin: 0;" data-language="${languageLabel.toLowerCase()}" data-code-id="${id}">${headerHtml}${codeHtml}</div>`;
|
||||
|
||||
codeBlocks.set(id, { html: wrapper, language: normalizedLanguage });
|
||||
return id;
|
||||
});
|
||||
html = this.convertTables(html);
|
||||
html = this.decorateInlineTags(html);
|
||||
|
||||
// Headings (H1-H6)
|
||||
let firstLevelOneHeadingSkipped = false;
|
||||
html = html.replace(/^(\#{1,6})\s+(.*)/gm, (match, hashes, content) => {
|
||||
const level = hashes.length;
|
||||
if (level === 1 && !firstLevelOneHeadingSkipped) {
|
||||
firstLevelOneHeadingSkipped = true;
|
||||
return '';
|
||||
}
|
||||
const id = this.slugify(content);
|
||||
const headingClass = `md-heading md-heading-${level}`;
|
||||
return `<h${level} id="${id}" class="${headingClass} text-obs-l-text-main dark:text-white font-bold mt-6 mb-3 pb-1 border-b border-obs-l-border dark:border-obs-d-border">${content}</h${level}>`;
|
||||
});
|
||||
|
||||
// Bold, Italic, Strikethrough
|
||||
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
|
||||
html = html.replace(/~~(.*?)~~/g, '<del>$1</del>');
|
||||
|
||||
// Blockquotes & Callouts
|
||||
html = html.replace(/^>\s?\[!(\w+)\]\n(>.*)/gm, (match, type, content) => {
|
||||
const calloutType = type.toUpperCase();
|
||||
const calloutClass = this.getCalloutClass(calloutType);
|
||||
const cleanedContent = content.replace(/^>\s?/gm, '');
|
||||
const title = this.formatCalloutTitle(calloutType);
|
||||
const bodyHtml = cleanedContent
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0)
|
||||
.map(line => `<p>${line}</p>`)
|
||||
.join('');
|
||||
|
||||
return `<div class="${calloutClass} text-left" style="max-width: 800px; width: 100%; margin: 0;" data-callout-type="${calloutType.toLowerCase()}"><div class="callout__title">${title}</div><div class="callout__body">${bodyHtml}</div></div>`;
|
||||
});
|
||||
html = html.replace(/^>\s(.*)/gm, '<blockquote class="border-l-4 border-gray-300 dark:border-gray-500 pl-4 py-2 my-4 italic text-gray-700 dark:text-gray-300">$1</blockquote>');
|
||||
|
||||
// Lists (simple implementation)
|
||||
html = html.replace(/^\s*[\-\*]\s(.*)/gm, '<li class="ml-6 list-disc">$1</li>');
|
||||
html = html.replace(/^\s*\d+\.\s(.*)/gm, '<li class="ml-6 list-decimal">$1</li>');
|
||||
html = html.replace(/<\/li>\n<li/g, '</li><li'); // Fix spacing
|
||||
html = html.replace(/(<li.*<\/li>)/gs, (match) => {
|
||||
if (match.includes('list-disc')) return `<ul class="mb-4">${match}</ul>`;
|
||||
if (match.includes('list-decimal')) return `<ol class="mb-4">${match}</ol>`;
|
||||
return match;
|
||||
});
|
||||
|
||||
// Inline Code
|
||||
html = html.replace(/(`+)([\s\S]*?)\1/g, (match, fence, inlineCode) => {
|
||||
if (inlineCode.includes('\n')) {
|
||||
return match;
|
||||
}
|
||||
return `<code class="inline-code">${this.escapeHtml(inlineCode)}</code>`;
|
||||
});
|
||||
|
||||
// Internal Links [[link|alias]] or [[link]]
|
||||
html = html.replace(/\[\[([^|\]\n]+)(?:\|([^\]\n]+))?\]\]/g, (match, link, alias) => {
|
||||
const targetText = alias || link;
|
||||
const targetId = link.toLowerCase().replace(/\s+/g, '-');
|
||||
const noteExists = allNotes.some(n => n.id === targetId || n.title === link);
|
||||
const classes = noteExists ? 'text-obs-l-accent dark:text-obs-d-accent hover:underline cursor-pointer' : 'text-red-500 dark:text-red-400 cursor-not-allowed';
|
||||
return `<a data-note-id="${targetId}" data-note-title="${link}" class="${classes}">${targetText}</a>`;
|
||||
});
|
||||
|
||||
// External Links [text](url)
|
||||
html = html.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" class="text-blue-600 dark:text-blue-400 hover:underline">$1</a>');
|
||||
|
||||
// Embedded Files ![[file.png]]
|
||||
html = html.replace(/!\[\[(.*?)\]\]/g, (match, filename) => {
|
||||
return `<figure class="my-4"><img src="https://picsum.photos/600/400?random=${Math.random()}" alt="${filename}" class="rounded-lg max-w-full h-auto mx-auto"><figcaption class="text-center text-sm text-obs-l-text-muted dark:text-obs-d-text-muted mt-2">${filename}</figcaption></figure>`;
|
||||
});
|
||||
|
||||
// Horizontal Rule
|
||||
html = html.replace(/^-{3,}/gm, '<hr class="my-6 border-obs-l-border dark:border-obs-d-border">');
|
||||
|
||||
// Paragraphs and line breaks
|
||||
html = html.split('\n').map(p => p.trim() === '' ? '' : `<p>${p}</p>`).join('').replace(/<\/p><p>/g, '</p><br><p>');
|
||||
|
||||
// Restore code blocks
|
||||
for (const [id, block] of codeBlocks.entries()) {
|
||||
html = html.replace(id, block.html);
|
||||
}
|
||||
|
||||
// Placeholder for unsupported elements
|
||||
html = html.replace(/\$\$(.*?)\$\$/g, '<div class="my-4 p-4 bg-obs-l-bg-secondary text-obs-l-text-muted dark:bg-obs-d-bg-secondary dark:text-obs-d-text-muted rounded text-center">[LaTeX Equation: $1]</div>');
|
||||
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
private getCalloutClass(type: string): string {
|
||||
const baseClass = 'callout';
|
||||
switch (type) {
|
||||
case 'NOTE':
|
||||
case 'INFO':
|
||||
return `${baseClass} callout-note`;
|
||||
case 'TIP':
|
||||
case 'TODO':
|
||||
return `${baseClass} callout-tip`;
|
||||
case 'WARNING':
|
||||
case 'CAUTION':
|
||||
return `${baseClass} callout-warning`;
|
||||
case 'DANGER':
|
||||
case 'ERROR':
|
||||
return `${baseClass} callout-danger`;
|
||||
default:
|
||||
return `${baseClass} callout-default`;
|
||||
}
|
||||
}
|
||||
|
||||
private formatCalloutTitle(type: string): string {
|
||||
const lower = type.toLowerCase();
|
||||
return lower.charAt(0).toUpperCase() + lower.slice(1);
|
||||
}
|
||||
|
||||
private decorateInlineTags(markdown: string): string {
|
||||
const tagRegex = /(^|[\s(>)])#([^\s#.,;:!?"'(){}\[\]<>]+)/g;
|
||||
return markdown.replace(tagRegex, (match, prefix, tag) => {
|
||||
if (!tag) {
|
||||
return match;
|
||||
}
|
||||
const trimmedTag = tag.trim();
|
||||
if (!trimmedTag) {
|
||||
return match;
|
||||
}
|
||||
const colorClass = this.getTagColorClass(trimmedTag);
|
||||
return `${prefix}<button type="button" class="md-tag-badge ${colorClass}" data-tag="${trimmedTag}" data-origin="inline">#${trimmedTag}</button>`;
|
||||
});
|
||||
}
|
||||
|
||||
private getTagColorClass(tag: string): string {
|
||||
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 index = hash % this.tagPaletteSize;
|
||||
this.tagColorCache.set(normalized, index);
|
||||
return `md-tag-color-${index}`;
|
||||
}
|
||||
|
||||
private convertTables(markdown: string): string {
|
||||
const lines = markdown.split('\n');
|
||||
const result: string[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < lines.length) {
|
||||
const currentLine = lines[index];
|
||||
if (this.isTableRow(currentLine) && this.isSeparatorRow(lines[index + 1] ?? '')) {
|
||||
const headerCells = this.splitTableRow(currentLine);
|
||||
const separatorLine = lines[index + 1];
|
||||
const rows: string[][] = [];
|
||||
index += 2;
|
||||
|
||||
while (index < lines.length && this.isTableRow(lines[index])) {
|
||||
rows.push(this.splitTableRow(lines[index]));
|
||||
index += 1;
|
||||
}
|
||||
|
||||
const columnCount = Math.max(
|
||||
headerCells.length,
|
||||
...rows.map(row => row.length)
|
||||
);
|
||||
const alignments = this.parseAlignments(separatorLine, columnCount);
|
||||
const headerHtml = Array.from({ length: columnCount }, (_, colIndex) => {
|
||||
const content = headerCells[colIndex] ?? '';
|
||||
return this.buildTableCell('th', content, alignments[colIndex]);
|
||||
}).join('');
|
||||
|
||||
const bodyHtml = rows.map(row => {
|
||||
const cells = Array.from({ length: columnCount }, (_, colIndex) => {
|
||||
const content = row[colIndex] ?? '';
|
||||
return this.buildTableCell('td', content, alignments[colIndex]);
|
||||
}).join('');
|
||||
return `<tr>${cells}</tr>`;
|
||||
}).join('');
|
||||
|
||||
const tableHtml = `<div class="markdown-table"><table><thead><tr>${headerHtml}</tr></thead>${rows.length ? `<tbody>${bodyHtml}</tbody>` : '<tbody></tbody>'}</table></div>`;
|
||||
result.push(tableHtml);
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push(currentLine);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
private isTableRow(line: string): boolean {
|
||||
if (!line) {
|
||||
return false;
|
||||
}
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith('|') || !trimmed.endsWith('|')) {
|
||||
return false;
|
||||
}
|
||||
const segments = this.splitTableRow(trimmed);
|
||||
return segments.length > 0;
|
||||
}
|
||||
|
||||
private isSeparatorRow(line: string): boolean {
|
||||
if (!line) {
|
||||
return false;
|
||||
}
|
||||
const segments = this.splitTableRow(line);
|
||||
if (!segments.length) {
|
||||
return false;
|
||||
}
|
||||
return segments.every(segment => {
|
||||
const normalized = segment.replace(/\s+/g, '');
|
||||
return /^:?-{3,}:?$/.test(normalized);
|
||||
});
|
||||
}
|
||||
|
||||
private splitTableRow(line: string): string[] {
|
||||
let trimmed = line.trim();
|
||||
if (trimmed.startsWith('|')) {
|
||||
trimmed = trimmed.slice(1);
|
||||
}
|
||||
if (trimmed.endsWith('|')) {
|
||||
trimmed = trimmed.slice(0, -1);
|
||||
}
|
||||
return trimmed.split('|').map(cell => cell.trim());
|
||||
}
|
||||
|
||||
private parseAlignments(separatorLine: string, columnCount: number): TableAlignment[] {
|
||||
const segments = this.splitTableRow(separatorLine);
|
||||
return Array.from({ length: columnCount }, (_, index) => {
|
||||
const segment = (segments[index] ?? '').trim();
|
||||
const normalized = segment.replace(/\s+/g, '');
|
||||
if (normalized.startsWith(':') && normalized.endsWith(':')) {
|
||||
return 'center';
|
||||
}
|
||||
if (normalized.startsWith(':')) {
|
||||
return 'left';
|
||||
}
|
||||
if (normalized.endsWith(':')) {
|
||||
return 'right';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private buildTableCell(tag: 'th' | 'td', content: string, alignment: TableAlignment): string {
|
||||
const alignStyle = alignment ? ` style="text-align:${alignment};"` : '';
|
||||
return `<${tag}${alignStyle}>${content}</${tag}>`;
|
||||
}
|
||||
|
||||
private escapeHtml(unsafe: string): string {
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
private slugify(text: string): string {
|
||||
return text.toString().toLowerCase()
|
||||
.replace(/\s+/g, '-') // Replace spaces with -
|
||||
.replace(/[^\w\-]+/g, '') // Remove all non-word chars
|
||||
.replace(/\-\-+/g, '-') // Replace multiple - with single -
|
||||
.replace(/^-+/, '') // Trim - from start of text
|
||||
.replace(/-+$/, ''); // Trim - from end of text
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import { Injectable, signal, computed } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Note, VaultNode, GraphData, TagInfo, VaultFolder } from '../types';
|
||||
|
||||
interface VaultApiNote {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
mtime: number;
|
||||
fileName?: string;
|
||||
filePath?: string;
|
||||
originalPath?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
interface VaultApiResponse {
|
||||
notes: VaultApiNote[];
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class VaultService {
|
||||
private notesMap = signal<Map<string, Note>>(new Map());
|
||||
private openFolderPaths = signal(new Set<string>());
|
||||
private initialVaultName = this.resolveVaultName();
|
||||
|
||||
allNotes = computed(() => Array.from(this.notesMap().values()));
|
||||
vaultName = signal<string>(this.initialVaultName);
|
||||
|
||||
fileTree = computed<VaultNode[]>(() => {
|
||||
const root: VaultFolder = { type: 'folder', name: 'root', path: '', children: [], isOpen: true };
|
||||
const folders = new Map<string, VaultFolder>([['', root]]);
|
||||
const openFolders = this.openFolderPaths();
|
||||
|
||||
const sortedNotes = this.allNotes().slice().sort((a, b) => {
|
||||
return a.originalPath.localeCompare(b.originalPath, undefined, { sensitivity: 'base' });
|
||||
});
|
||||
|
||||
for (const note of sortedNotes) {
|
||||
const originalSegments = note.originalPath.split('/').filter(Boolean);
|
||||
const folderSegments = originalSegments.slice(0, -1);
|
||||
|
||||
let currentPath = '';
|
||||
let parentFolder = root;
|
||||
|
||||
for (const segment of folderSegments) {
|
||||
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
||||
let folder = folders.get(currentPath);
|
||||
if (!folder) {
|
||||
folder = {
|
||||
type: 'folder',
|
||||
name: segment,
|
||||
path: currentPath,
|
||||
children: [],
|
||||
isOpen: openFolders.has(currentPath)
|
||||
};
|
||||
folders.set(currentPath, folder);
|
||||
parentFolder.children.push(folder);
|
||||
} else {
|
||||
folder.isOpen = openFolders.has(currentPath);
|
||||
}
|
||||
parentFolder = folder;
|
||||
}
|
||||
|
||||
parentFolder.children.push({
|
||||
type: 'file',
|
||||
name: note.fileName,
|
||||
path: note.filePath.startsWith('/') ? note.filePath : `/${note.filePath}`,
|
||||
id: note.id
|
||||
});
|
||||
}
|
||||
|
||||
const sortChildren = (node: VaultFolder) => {
|
||||
node.children.sort((a, b) => {
|
||||
if (a.type === 'folder' && b.type === 'file') return -1;
|
||||
if (a.type === 'file' && b.type === 'folder') return 1;
|
||||
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
||||
});
|
||||
node.children.forEach(child => {
|
||||
if (child.type === 'folder') {
|
||||
sortChildren(child);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
sortChildren(root);
|
||||
|
||||
return root.children;
|
||||
});
|
||||
|
||||
graphData = computed<GraphData>(() => {
|
||||
const notes = this.allNotes();
|
||||
const nodes = notes.map(note => ({ id: note.id, label: note.title }));
|
||||
const edges: { source: string, target: string }[] = [];
|
||||
|
||||
for (const note of notes) {
|
||||
const linkRegex = /\[\[([^|\]\n]+)(?:\|([^\]\n]+))?\]\]/g;
|
||||
let match;
|
||||
while ((match = linkRegex.exec(note.content)) !== null) {
|
||||
const linkPath = match[1].toLowerCase().replace(/\s+/g, '-');
|
||||
const targetNote = notes.find(n => n.id === linkPath || n.title === match[1] || (n.frontmatter?.aliases as string[])?.includes(match[1]));
|
||||
if (targetNote && targetNote.id !== note.id) {
|
||||
edges.push({ source: note.id, target: targetNote.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
});
|
||||
|
||||
tags = computed<TagInfo[]>(() => {
|
||||
const tagCounts = new Map<string, number>();
|
||||
for (const note of this.allNotes()) {
|
||||
for (const tag of note.tags) {
|
||||
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
|
||||
}
|
||||
}
|
||||
return Array.from(tagCounts.entries())
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
});
|
||||
|
||||
constructor(private http: HttpClient) {
|
||||
this.refreshNotes();
|
||||
}
|
||||
|
||||
getNoteById(id: string): Note | undefined {
|
||||
return this.notesMap().get(id);
|
||||
}
|
||||
|
||||
toggleFolder(path: string): void {
|
||||
this.openFolderPaths.update(paths => {
|
||||
const newPaths = new Set(paths);
|
||||
if (newPaths.has(path)) {
|
||||
newPaths.delete(path);
|
||||
} else {
|
||||
newPaths.add(path);
|
||||
}
|
||||
return newPaths;
|
||||
});
|
||||
}
|
||||
|
||||
ensureFolderOpen(originalPath: string): void {
|
||||
if (!originalPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = originalPath.split('/').filter(Boolean);
|
||||
if (parts.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedPaths = new Set(this.openFolderPaths());
|
||||
let currentPath = '';
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
|
||||
updatedPaths.add(currentPath);
|
||||
}
|
||||
|
||||
this.openFolderPaths.set(updatedPaths);
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
this.refreshNotes();
|
||||
}
|
||||
|
||||
private refreshNotes() {
|
||||
this.http.get<VaultApiResponse>('/api/vault').subscribe({
|
||||
next: ({ notes }) => {
|
||||
const newNotesMap = new Map<string, Note>();
|
||||
|
||||
let detectedHomeVaultName: string | null = null;
|
||||
|
||||
for (const apiNote of notes) {
|
||||
const { frontmatter, body } = this.parseFrontmatter(apiNote.content);
|
||||
const derivedTitle = this.extractTitle(body, apiNote.id);
|
||||
const noteTitle = frontmatter.title || apiNote.title || derivedTitle;
|
||||
|
||||
const originalPath = (apiNote.originalPath ?? apiNote.filePath?.replace(/\.md$/i, '') ?? apiNote.id).replace(/\\/g, '/');
|
||||
const fileName = apiNote.fileName ?? (() => {
|
||||
const parts = originalPath.split('/').filter(Boolean);
|
||||
const lastSegment = parts.pop();
|
||||
if (apiNote.filePath) {
|
||||
return apiNote.filePath.split('/').pop() ?? `${lastSegment ?? apiNote.id}.md`;
|
||||
}
|
||||
return `${lastSegment ?? apiNote.id}.md`;
|
||||
})();
|
||||
const filePath = apiNote.filePath ?? `${originalPath}.md`;
|
||||
|
||||
const tagSet = new Set<string>();
|
||||
if (Array.isArray(apiNote.tags)) {
|
||||
apiNote.tags.forEach(tag => tagSet.add(tag));
|
||||
}
|
||||
if (Array.isArray(frontmatter.tags)) {
|
||||
(frontmatter.tags as string[]).forEach(tag => tagSet.add(tag));
|
||||
}
|
||||
|
||||
const fallbackUpdatedAt = new Date((frontmatter.mtime ?? apiNote.mtime) || Date.now()).toISOString();
|
||||
const note: Note = {
|
||||
id: apiNote.id,
|
||||
title: noteTitle,
|
||||
content: body,
|
||||
tags: Array.from(tagSet),
|
||||
frontmatter,
|
||||
backlinks: [],
|
||||
mtime: frontmatter.mtime || apiNote.mtime || Date.now(),
|
||||
fileName,
|
||||
filePath,
|
||||
originalPath,
|
||||
createdAt: typeof apiNote.createdAt === 'string' ? apiNote.createdAt : undefined,
|
||||
updatedAt: typeof apiNote.updatedAt === 'string' ? apiNote.updatedAt : fallbackUpdatedAt
|
||||
};
|
||||
|
||||
if (this.shouldUseVaultName(frontmatter, apiNote.filePath, apiNote.originalPath)) {
|
||||
const candidateName = frontmatter.NomDeVoute || frontmatter.nomdevoute || frontmatter.vaultName;
|
||||
if (typeof candidateName === 'string' && candidateName.trim()) {
|
||||
detectedHomeVaultName = candidateName.trim();
|
||||
}
|
||||
}
|
||||
|
||||
newNotesMap.set(apiNote.id, note);
|
||||
}
|
||||
|
||||
this.computeBacklinks(newNotesMap);
|
||||
this.notesMap.set(newNotesMap);
|
||||
|
||||
if (detectedHomeVaultName) {
|
||||
this.vaultName.set(this.formatVaultName(detectedHomeVaultName));
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Failed to load vault notes from API', error);
|
||||
this.notesMap.set(new Map());
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private computeBacklinks(notesMap: Map<string, Note>) {
|
||||
const allNotes = Array.from(notesMap.values());
|
||||
allNotes.forEach(note => {
|
||||
note.backlinks = [];
|
||||
});
|
||||
|
||||
for (const note of allNotes) {
|
||||
const linkRegex = /\[\[([^|\]\n]+)(?:\|([^\]\n]+))?\]\]/g;
|
||||
let match;
|
||||
while ((match = linkRegex.exec(note.content)) !== null) {
|
||||
const linkTarget = match[1].toLowerCase().replace(/\s+/g, '-');
|
||||
const targetNote = allNotes.find(n =>
|
||||
n.id === linkTarget ||
|
||||
n.title === match[1] ||
|
||||
(Array.isArray(n.frontmatter?.aliases) && (n.frontmatter.aliases as string[]).includes(match[1]))
|
||||
);
|
||||
|
||||
if (targetNote && targetNote.id !== note.id) {
|
||||
const targetInMap = notesMap.get(targetNote.id);
|
||||
if (targetInMap && !targetInMap.backlinks.includes(note.id)) {
|
||||
targetInMap.backlinks.push(note.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private parseFrontmatter(content: string): { frontmatter: { [key: string]: any }, body: string } {
|
||||
const sanitizedContent = content.replace(/^\uFEFF/, '');
|
||||
const normalizedContent = sanitizedContent.replace(/\r\n?/g, '\n');
|
||||
const match = normalizedContent.match(/^---\n([\s\S]+?)\n---\n?([\s\S]*)/);
|
||||
if (match) {
|
||||
const frontmatterText = match[1];
|
||||
const body = match[2].trim();
|
||||
const frontmatter: { [key: string]: any } = {};
|
||||
const lines = frontmatterText.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const rawLine = lines[i];
|
||||
if (!rawLine.trim() || rawLine.trim().startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const colonIndex = rawLine.indexOf(':');
|
||||
if (colonIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = rawLine.slice(0, colonIndex).trim();
|
||||
let valuePart = rawLine.slice(colonIndex + 1).trim();
|
||||
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!valuePart) {
|
||||
const listValues: any[] = [];
|
||||
let j = i + 1;
|
||||
while (j < lines.length) {
|
||||
const listLine = lines[j];
|
||||
if (!listLine.trim()) {
|
||||
break;
|
||||
}
|
||||
if (/^\s*-\s+/.test(listLine)) {
|
||||
const listItem = listLine.replace(/^\s*-\s+/, '');
|
||||
listValues.push(this.parseFrontmatterValue(listItem.trim()));
|
||||
j++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (listValues.length) {
|
||||
frontmatter[key] = listValues;
|
||||
i = j - 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (valuePart.startsWith('[') && valuePart.endsWith(']')) {
|
||||
const arrayContent = valuePart.substring(1, valuePart.length - 1);
|
||||
frontmatter[key] = arrayContent.split(',').map(v => this.parseFrontmatterValue(v.trim()));
|
||||
continue;
|
||||
}
|
||||
|
||||
frontmatter[key] = this.parseFrontmatterValue(valuePart);
|
||||
}
|
||||
return { frontmatter, body };
|
||||
}
|
||||
return { frontmatter: {}, body: content.trim() };
|
||||
}
|
||||
|
||||
private parseFrontmatterValue(rawValue: string): any {
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const unquoted = trimmed.replace(/^['"]|['"]$/g, '');
|
||||
|
||||
if (/^(true|false)$/i.test(unquoted)) {
|
||||
return unquoted.toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
const numeric = Number(unquoted);
|
||||
if (!isNaN(numeric) && unquoted !== '') {
|
||||
return numeric;
|
||||
}
|
||||
|
||||
return unquoted;
|
||||
}
|
||||
|
||||
private extractTitle(content: string, fallback: string): string {
|
||||
const match = content.match(/^#\s+(.*)/);
|
||||
return match ? match[1] : fallback.split('/').pop()!.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
}
|
||||
|
||||
private resolveVaultName(): string {
|
||||
const homeFile = (window as any)?.APP_CONFIG?.vault?.home;
|
||||
if (homeFile && typeof homeFile === 'string') {
|
||||
const segments = homeFile.split(/[\/]/).filter(Boolean);
|
||||
if (segments.length) {
|
||||
return this.formatVaultName(segments[segments.length - 1]);
|
||||
}
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
const appConfig = (window as any).APP_CONFIG;
|
||||
const explicitName = appConfig?.vault?.name;
|
||||
if (explicitName && typeof explicitName === 'string') {
|
||||
return explicitName;
|
||||
}
|
||||
|
||||
const configuredPath = appConfig?.vault?.path;
|
||||
if (configuredPath && typeof configuredPath === 'string') {
|
||||
const segments = configuredPath.split(/[\\/]/).filter(Boolean);
|
||||
if (segments.length) {
|
||||
return this.formatVaultName(segments[segments.length - 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 'Vault';
|
||||
}
|
||||
|
||||
private shouldUseVaultName(frontmatter: { [key: string]: any }, filePath?: string, originalPath?: string): boolean {
|
||||
const hasMetaName = typeof frontmatter?.NomDeVoute === 'string'
|
||||
|| typeof frontmatter?.nomdevoute === 'string'
|
||||
|| typeof frontmatter?.vaultName === 'string';
|
||||
if (!hasMetaName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedPath = (filePath ?? originalPath ?? '').replace(/\\/g, '/');
|
||||
const pathSegments = normalizedPath.split('/').filter(Boolean).map(segment => segment.toLowerCase());
|
||||
if (!pathSegments.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastSegment = pathSegments[pathSegments.length - 1];
|
||||
return pathSegments.length === 1 && (lastSegment === 'home.md' || lastSegment === 'home');
|
||||
}
|
||||
|
||||
private formatVaultName(value: string): string {
|
||||
return value
|
||||
.replace(/[-_]+/g, ' ')
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html, body {
|
||||
height: auto;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
body {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.calendar-compact {
|
||||
@apply flex min-h-[180px] max-h-[200px] flex-col;
|
||||
}
|
||||
|
||||
.calendar-compact .cal-month-view {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.calendar-compact .cal-header {
|
||||
@apply px-2 py-1;
|
||||
}
|
||||
|
||||
.calendar-compact .cal-header .cal-header-title {
|
||||
@apply text-sm;
|
||||
}
|
||||
|
||||
.calendar-compact .cal-cell {
|
||||
min-height: 36px;
|
||||
padding: 0.35rem;
|
||||
}
|
||||
|
||||
.calendar-compact .cal-day-number {
|
||||
@apply text-xs;
|
||||
}
|
||||
|
||||
.calendar-compact .cal-event {
|
||||
@apply h-0.5 rounded-full mt-[2px];
|
||||
}
|
||||
|
||||
.cal-event-created {
|
||||
background-color: #22c55e;
|
||||
}
|
||||
|
||||
.cal-event-updated {
|
||||
background-color: #f97316;
|
||||
}
|
||||
|
||||
.calendar-compact .cal-open-day-events {
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export type { FileMetadata } from './types/file-metadata.model';
|
||||
|
||||
export interface Note {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
frontmatter: { [key: string]: any };
|
||||
backlinks: string[];
|
||||
mtime: number; // last modified time
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
originalPath: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface VaultFile {
|
||||
type: 'file';
|
||||
name: string;
|
||||
path: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface VaultFolder {
|
||||
type: 'folder';
|
||||
name: string;
|
||||
path: string;
|
||||
children: VaultNode[];
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export type VaultNode = VaultFile | VaultFolder;
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
source: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
export interface GraphData {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
}
|
||||
|
||||
export interface TagInfo {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface FileMetadata {
|
||||
id: string;
|
||||
title: string;
|
||||
path: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
declare module 'mermaid' {
|
||||
export interface MermaidConfig {
|
||||
startOnLoad?: boolean;
|
||||
securityLevel?: 'strict' | 'loose';
|
||||
theme?: 'default' | 'dark' | string;
|
||||
}
|
||||
|
||||
export interface RenderResult {
|
||||
svg: string;
|
||||
bindFunctions?: (element: Element) => void;
|
||||
}
|
||||
|
||||
export interface MermaidApi {
|
||||
initialize(config: MermaidConfig): void;
|
||||
render(id: string, definition: string): Promise<RenderResult>;
|
||||
}
|
||||
|
||||
const mermaid: MermaidApi;
|
||||
export default mermaid;
|
||||
}
|
||||
Reference in New Issue
Block a user