ObsiViewer/src/app/features/sidebar/nimbus-sidebar.component.ts
Bruno Charest cbdb000d4b feat: enhance navigation and URL state management
- 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
2025-10-30 21:34:45 -04:00

304 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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">&lt;/&gt;</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';
}
}