- Added clickable "Quick Links" header to navigate to all pages view - Implemented URL normalization to handle multiple section params (tag/folder/quick) with priority rules - Added suppressNextNoteSelection flag to prevent auto-selection for certain quick link actions - Updated URL state service to use setQuickWithMarkdown for consistent navigation state - Improved path normalization to handle backslashes and leading slashes consistently - Added distinct
304 lines
15 KiB
TypeScript
304 lines
15 KiB
TypeScript
import { Component, EventEmitter, Input, Output, ViewChild, inject, OnChanges, SimpleChanges } from '@angular/core';
|
||
import { CommonModule } from '@angular/common';
|
||
import { RouterModule } from '@angular/router';
|
||
import { FileExplorerComponent } from '../../../components/file-explorer/file-explorer.component';
|
||
import { QuickLinksComponent } from '../quick-links/quick-links.component';
|
||
import { ScrollableOverlayDirective } from '../../shared/overlay-scrollbar/scrollable-overlay.directive';
|
||
import type { VaultNode, TagInfo } from '../../../types';
|
||
import { environment } from '../../../environments/environment';
|
||
import { VaultService } from '../../../services/vault.service';
|
||
import { UrlStateService } from '../../services/url-state.service';
|
||
import { SidebarStateService } from '../../services/sidebar-state.service';
|
||
import { FilterService } from '../../services/filter.service';
|
||
|
||
@Component({
|
||
selector: 'app-nimbus-sidebar',
|
||
standalone: true,
|
||
imports: [CommonModule, RouterModule, FileExplorerComponent, QuickLinksComponent, ScrollableOverlayDirective],
|
||
host: { class: 'block h-full' },
|
||
template: `
|
||
<div class="h-full flex flex-col overflow-hidden select-none">
|
||
<!-- Header -->
|
||
<div class="h-12 flex items-center justify-between px-3 border-b border-border dark:border-gray-800">
|
||
<a href="/" (click)="onHomeClick($event)" class="flex items-center gap-2 min-w-0 text-inherit no-underline">
|
||
<img src="assets/favicon.svg" alt="ObsiViewer" class="h-6 w-6 flex-shrink-0" />
|
||
<span class="text-sm font-semibold truncate">{{ vaultName }} - ObsiViewer</span>
|
||
</a>
|
||
<button (click)="toggleSidebarRequest.emit()" class="p-2 rounded hover:bg-surface1 dark:hover:bg-card" title="Hide Sidebar">⟨⟨</button>
|
||
</div>
|
||
|
||
<!-- Content (scroll) -->
|
||
<div class="flex-1 overflow-y-auto min-h-0" appScrollableOverlay>
|
||
<!-- Section Tests (dev-only) -->
|
||
<section *ngIf="env.features.showTestSection" class="border-b border-border dark:border-gray-800">
|
||
<button class="w-full flex items-center justify-between px-3 py-2 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card"
|
||
(click)="open.tests = !open.tests">
|
||
<span class="flex items-center gap-2">🧪 <span>Section Tests</span></span>
|
||
<span class="text-xs text-muted">{{ open.tests ? '▾' : '▸' }}</span>
|
||
</button>
|
||
<div *ngIf="open.tests" class="px-3 py-2">
|
||
<button
|
||
(click)="onMarkdownPlaygroundClick()"
|
||
class="w-full text-left block text-sm px-2 py-1.5 rounded hover:bg-surface1 dark:hover:bg-card text-main dark:text-main hover:text-main dark:hover:text-gray-100">
|
||
Markdown Playground
|
||
</button>
|
||
<button
|
||
(click)="onApiTestsPanelClick()"
|
||
class="mt-1 w-full text-left block text-sm px-2 py-1.5 rounded hover:bg-surface1 dark:hover:bg-card text-main dark:text-main hover:text-main dark:hover:text-gray-100">
|
||
API Tests Panel
|
||
</button>
|
||
<button
|
||
(click)="testsExcalidrawSelected.emit()"
|
||
class="mt-1 w-full text-left block text-sm px-2 py-1.5 rounded hover:bg-surface1 dark:hover:bg-card text-main dark:text-main hover:text-main dark:hover:text-gray-100">
|
||
Test Excalidraw
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Quick Links accordion -->
|
||
<section class="border-b border-border dark:border-gray-800">
|
||
<button class="w-full flex items-center px-3 py-2 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card"
|
||
(click)="toggleSection('quick')">
|
||
<span class="flex items-center gap-2">
|
||
<span class="text-xs text-muted">{{ open.quick ? '▾' : '▸' }}</span>
|
||
<span>⚡</span>
|
||
<a href="/" (click)="$event.stopPropagation(); onQuickLinksHeaderClick($event)" class="hover:underline">Quick Links</a>
|
||
</span>
|
||
</button>
|
||
<div *ngIf="open.quick" class="pt-1">
|
||
<app-quick-links (quickLinkSelected)="onQuickLink($event)"></app-quick-links>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Folders accordion -->
|
||
<section class="border-b border-border dark:border-gray-800">
|
||
<div class="w-full flex items-center px-3 py-2 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card">
|
||
<button class="flex items-center gap-2 flex-1 text-left" (click)="toggleSection('folders')">
|
||
<span class="text-xs text-muted">{{ open.folders ? '▾' : '▸' }}</span>
|
||
<span>📁</span>
|
||
<span>Folders</span>
|
||
</button>
|
||
<button *ngIf="open.folders" (click)="urlState.showAllAndReset()" title="Afficher tous les fichiers et réinitialiser la recherche" class="flex items-center gap-1 p-2 rounded hover:bg-surface1 dark:hover:bg-card mr-1">
|
||
✨
|
||
</button>
|
||
<button *ngIf="open.folders" (click)="onCreateFolderAtRoot()" title="Create Folder" class="flex items-center gap-1 p-2 rounded hover:bg-surface1 dark:hover:bg-card">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 -ml-1" viewBox="0 0 20 20" fill="currentColor">
|
||
<path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 010 2h-3v3a1 1 0 01-2 0v-3H6a1 1 0 010-2h3V6a1 1 0 011-1z" clip-rule="evenodd" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div *ngIf="open.folders" class="px-1 py-1">
|
||
<div class="flex gap-2 flex-wrap px-2 pb-2">
|
||
<button type="button" class="px-2 py-1 rounded text-xs"
|
||
[ngClass]="chipClass(filters.isKindActive('image'))"
|
||
(click)="setKind('image')" title="Images">🖼️</button>
|
||
<button type="button" class="px-2 py-1 rounded text-xs"
|
||
[ngClass]="chipClass(filters.isKindActive('video'))"
|
||
(click)="setKind('video')" title="Vidéos">🎬</button>
|
||
<button type="button" class="px-2 py-1 rounded text-xs"
|
||
[ngClass]="chipClass(filters.isKindActive('pdf'))"
|
||
(click)="setKind('pdf')" title="PDF">📄</button>
|
||
<button type="button" class="px-2 py-1 rounded text-xs"
|
||
[ngClass]="chipClass(filters.isKindActive('markdown'))"
|
||
(click)="setKind('markdown')" title="Markdown">📝</button>
|
||
<button type="button" class="px-2 py-1 rounded text-xs"
|
||
[ngClass]="chipClass(filters.isKindActive('excalidraw'))"
|
||
(click)="setKind('excalidraw')" title="Excalidraw">✏️</button>
|
||
<button type="button" class="px-2 py-1 rounded text-xs font-mono"
|
||
[ngClass]="chipClass(filters.isKindActive('code'))"
|
||
(click)="setKind('code')" title="Code"></></button>
|
||
<button type="button" class="px-2 py-1 rounded text-xs"
|
||
[ngClass]="chipClass(filters.isKindActive('all'))"
|
||
(click)="setKind('all')" title="Tout">✨ Tout</button>
|
||
</div>
|
||
<app-file-explorer
|
||
#foldersExplorer
|
||
[nodes]="effectiveFileTree"
|
||
[selectedNoteId]="selectedNoteId"
|
||
[foldersOnly]="true"
|
||
[quickLinkFilter]="quickLinkFilter"
|
||
(folderSelected)="folderSelected.emit($event)"
|
||
(fileSelected)="fileSelected.emit($event)">
|
||
</app-file-explorer>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Tags accordion -->
|
||
<section class="border-b border-border dark:border-gray-800">
|
||
<button class="w-full flex items-center px-3 py-2 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card"
|
||
(click)="toggleSection('tags')">
|
||
<span class="flex items-center gap-2">
|
||
<span class="text-xs text-muted">{{ open.tags ? '▾' : '▸' }}</span>
|
||
<span>🏷️</span>
|
||
<span>Tags</span>
|
||
</span>
|
||
</button>
|
||
<div *ngIf="open.tags" class="px-2 py-2">
|
||
<ul class="space-y-0.5 text-sm">
|
||
<li *ngFor="let t of tags" class="flex items-center gap-2">
|
||
<button (click)="tagSelected.emit(t.name)" class="flex-1 text-left px-2.5 py-1.5 rounded-lg transition-colors hover:bg-slate-500/10 dark:hover:bg-surface2/15 truncate">
|
||
<span>🏷️</span>
|
||
<span class="ml-1">{{ t.name }}</span>
|
||
</button>
|
||
<span class="text-xs text-muted">{{ t.count }}</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Trash accordion -->
|
||
<section class="border-b border-border dark:border-gray-800">
|
||
<button class="w-full flex items-center px-3 py-2 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card"
|
||
(click)="toggleTrashSection()">
|
||
<span class="flex items-center gap-2">
|
||
<span class="text-xs text-muted">{{ open.trash ? '▾' : '▸' }}</span>
|
||
<span>🗑️</span>
|
||
<span>Trash</span>
|
||
</span>
|
||
</button>
|
||
<div *ngIf="open.trash" class="px-1 py-2">
|
||
<ng-container *ngIf="trashHasContent(); else emptyTrash">
|
||
<app-file-explorer
|
||
[nodes]="vault.trashTree()"
|
||
[selectedNoteId]="selectedNoteId"
|
||
[foldersOnly]="true"
|
||
[useTrashCounts]="true"
|
||
(folderSelected)="folderSelected.emit($event)"
|
||
(fileSelected)="fileSelected.emit($event)">
|
||
</app-file-explorer>
|
||
</ng-container>
|
||
<ng-template #emptyTrash>
|
||
<div class="px-3 py-2 text-muted text-sm">La corbeille est vide</div>
|
||
</ng-template>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Help & About section -->
|
||
<section class="border-b border-border dark:border-gray-800">
|
||
<div class="px-2 py-2 space-y-1">
|
||
<button
|
||
(click)="helpPageSelected.emit()"
|
||
class="w-full text-left flex items-center gap-2 px-3 py-2 text-sm rounded-lg hover:bg-surface1 dark:hover:bg-card transition-colors">
|
||
<span>🆘</span>
|
||
<span>Help Page</span>
|
||
</button>
|
||
<button
|
||
(click)="aboutSelected.emit()"
|
||
class="w-full text-left flex items-center gap-2 px-3 py-2 text-sm rounded-lg hover:bg-surface1 dark:hover:bg-card transition-colors">
|
||
<span>ℹ️</span>
|
||
<span>About</span>
|
||
</button>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<!-- Footer placeholder -->
|
||
<div class="h-14 border-t border-border dark:border-gray-800 flex items-center px-3 text-xs text-muted">ObsiViewer</div>
|
||
</div>
|
||
`
|
||
})
|
||
export class NimbusSidebarComponent implements OnChanges {
|
||
@Input() vaultName = '';
|
||
@Input() effectiveFileTree: VaultNode[] = [];
|
||
@Input() selectedNoteId: string | null = null;
|
||
@Input() tags: TagInfo[] = [];
|
||
@Input() quickLinkFilter: 'favoris' | 'publish' | 'draft' | 'template' | 'task' | 'private' | 'archive' | null = null;
|
||
@Input() forceOpenSection: 'folders' | 'tags' | 'quick' | null = null;
|
||
|
||
@Output() toggleSidebarRequest = new EventEmitter<void>();
|
||
@Output() folderSelected = new EventEmitter<string>();
|
||
@Output() fileSelected = new EventEmitter<string>();
|
||
@Output() tagSelected = new EventEmitter<string>();
|
||
@Output() quickLinkSelected = new EventEmitter<string>();
|
||
@Output() markdownPlaygroundSelected = new EventEmitter<void>();
|
||
@Output() testsPanelSelected = new EventEmitter<void>();
|
||
@Output() testsExcalidrawSelected = new EventEmitter<void>();
|
||
@Output() helpPageSelected = new EventEmitter<void>();
|
||
@Output() aboutSelected = new EventEmitter<void>();
|
||
|
||
env = environment;
|
||
open = { quick: true, folders: false, tags: false, trash: false, tests: false };
|
||
private vault = inject(VaultService);
|
||
urlState = inject(UrlStateService);
|
||
private sidebar = inject(SidebarStateService);
|
||
filters = inject(FilterService);
|
||
@ViewChild('foldersExplorer') private foldersExplorer?: FileExplorerComponent;
|
||
|
||
ngOnChanges(changes: SimpleChanges): void {
|
||
if (changes['forceOpenSection']) {
|
||
const which = this.forceOpenSection;
|
||
if (which === 'folders') {
|
||
this.open = { quick: false, folders: true, tags: false, trash: false, tests: false };
|
||
} else if (which === 'tags') {
|
||
this.open = { quick: false, folders: false, tags: true, trash: false, tests: false };
|
||
} else if (which === 'quick') {
|
||
this.open = { quick: true, folders: false, tags: false, trash: false, tests: false };
|
||
}
|
||
}
|
||
}
|
||
|
||
onQuickLink(id: string) { this.quickLinkSelected.emit(id); }
|
||
|
||
onHomeClick(event: MouseEvent): void {
|
||
event.preventDefault();
|
||
this.open = { quick: true, folders: false, tags: false, trash: false, tests: false };
|
||
this.sidebar.open('quick');
|
||
void this.urlState.setQuickWithMarkdown('all');
|
||
}
|
||
|
||
onQuickLinksHeaderClick(event: MouseEvent): void {
|
||
event.preventDefault();
|
||
this.open = { quick: true, folders: false, tags: false, trash: false, tests: false };
|
||
this.sidebar.open('quick');
|
||
void this.urlState.setQuickWithMarkdown('all');
|
||
}
|
||
|
||
onMarkdownPlaygroundClick(): void {
|
||
this.markdownPlaygroundSelected.emit();
|
||
}
|
||
|
||
onApiTestsPanelClick(): void {
|
||
this.testsPanelSelected.emit();
|
||
}
|
||
|
||
trashNotes = () => this.vault.trashNotes();
|
||
trashCount = () => this.vault.counts().trash;
|
||
trashHasContent = () => (this.vault.trashTree() || []).length > 0;
|
||
trackNoteId = (_: number, n: { id: string }) => n.id;
|
||
|
||
toggleSection(which: 'quick' | 'folders' | 'tags'): void {
|
||
// Open requested section, close others, and reset filters/search via SidebarStateService
|
||
this.open = { quick: false, folders: false, tags: false, trash: false, tests: false };
|
||
(this.open as any)[which] = true;
|
||
this.sidebar.open(which);
|
||
}
|
||
|
||
onCreateFolderAtRoot(): void {
|
||
// If not yet rendered, open the section first, then defer action
|
||
if (!this.open.folders) {
|
||
this.open.folders = true;
|
||
setTimeout(() => this.foldersExplorer?.openCreateAtRoot(), 0);
|
||
} else {
|
||
this.foldersExplorer?.openCreateAtRoot();
|
||
}
|
||
}
|
||
|
||
toggleTrashSection(): void {
|
||
const next = !this.open.trash;
|
||
this.open.trash = next;
|
||
if (next) {
|
||
this.folderSelected.emit('.trash');
|
||
}
|
||
}
|
||
|
||
setKind(kind: 'image'|'video'|'pdf'|'markdown'|'excalidraw'|'code'|'all') {
|
||
this.filters.toggleKind(kind === 'all' ? 'all' : kind as any);
|
||
}
|
||
|
||
chipClass(active: boolean): string {
|
||
return active
|
||
? 'bg-primary/15 text-primary ring-1 ring-primary/40'
|
||
: 'bg-surface1/50 text-muted hover:bg-surface1 dark:hover:bg-card';
|
||
}
|
||
}
|