feat: redesign UI with simplified header and enhanced mobile navigation

This commit is contained in:
2025-10-17 14:40:07 -04:00
parent e2775a3d43
commit 8cccf83f9a
9 changed files with 301 additions and 139 deletions
@@ -7,22 +7,38 @@ import { MobileNavService } from '../../shared/services/mobile-nav.service';
standalone: true,
imports: [CommonModule],
template: `
<nav class="fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800 h-16 flex justify-around items-center z-50">
<nav class="fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800 h-16 flex justify-around items-center z-50 safe-area-inset-bottom">
<button *ngFor="let tab of tabs"
(click)="setActiveTab(tab.id)"
class="flex-1 flex flex-col items-center justify-center gap-1 text-xs py-2 px-1 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
[class.text-nimbus-500]="mobileNav.activeTab() === tab.id">
<span class="text-lg">{{ tab.icon }}</span>
class="relative flex-1 flex flex-col items-center justify-center gap-1 text-xs py-2 px-1 text-gray-600 dark:text-gray-400 transition-all duration-200 active:scale-95 transform"
[class.text-nimbus-500]="mobileNav.activeTab() === tab.id"
[class.font-semibold]="mobileNav.activeTab() === tab.id">
<!-- Active indicator -->
<div
class="absolute top-0 left-1/2 -translate-x-1/2 w-12 h-0.5 bg-nimbus-500 rounded-full transition-all duration-300"
[class.opacity-100]="mobileNav.activeTab() === tab.id"
[class.opacity-0]="mobileNav.activeTab() !== tab.id">
</div>
<span
class="text-lg transition-transform duration-200"
[class.scale-110]="mobileNav.activeTab() === tab.id">
{{ tab.icon }}
</span>
<span class="truncate">{{ tab.label }}</span>
</button>
</nav>
`
`,
styles: [`
.safe-area-inset-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
`]
})
export class AppBottomNavigationComponent {
mobileNav = inject(MobileNavService);
tabs = [
{ id: 'sidebar', icon: '📁', label: 'Dossiers' },
{ id: 'sidebar', icon: '🏠', label: 'Maison' },
{ id: 'list', icon: '🔍', label: 'Liste' },
{ id: 'page', icon: '📄', label: 'Page' },
{ id: 'toc', icon: '📋', label: 'Sommaire' }
@@ -1,33 +1,93 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { Component, EventEmitter, OnInit, Output, ChangeDetectionStrategy, ChangeDetectorRef, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ScrollableOverlayDirective } from '../../shared/overlay-scrollbar/scrollable-overlay.directive';
@Component({
selector: 'app-toc-overlay',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, ScrollableOverlayDirective],
template: `
<div class="fixed inset-0 z-50 bg-black/50" (click)="close.emit()"></div>
<div class="fixed inset-x-0 bottom-0 z-50 max-h-[70vh] rounded-t-2xl bg-white dark:bg-gray-900 shadow-2xl border-t border-gray-200 dark:border-gray-800">
<div class="p-4 flex items-center justify-between border-b border-gray-200 dark:border-gray-800">
<h2 class="text-base font-semibold">Sommaire</h2>
<button (click)="close.emit()" class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800">✕</button>
<!-- Backdrop -->
<div
class="fixed inset-0 z-[80] bg-black/50 transition-opacity duration-300"
[class.opacity-0]="!isVisible"
[class.opacity-100]="isVisible"
[style.pointer-events]="isVisible ? 'auto' : 'none'"
(pointerdown)="onBackdropPointerDown($event)"
aria-hidden="true">
</div>
<!-- Bottom sheet -->
<div
class="fixed inset-x-0 bottom-0 z-[81] max-h-[85vh] rounded-t-2xl bg-white dark:bg-slate-900 shadow-2xl
transition-transform duration-300 ease-out
sm:left-auto sm:right-4 sm:bottom-4 sm:top-auto sm:w-[420px] sm:rounded-2xl sm:max-h-[80vh]"
[class.translate-y-full]="!isVisible"
[class.translate-y-0]="isVisible"
[style.pointer-events]="isVisible ? 'auto' : 'none'"
role="dialog"
aria-modal="true"
(click)="$event.stopPropagation()">
<div class="flex items-center justify-center py-3 sm:py-2">
<div class="h-1.5 w-12 rounded-full bg-slate-300 dark:bg-slate-700"></div>
</div>
<div class="p-3 overflow-y-auto">
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
<li *ngFor="let h of headings">
<a (click)="onGo(h.id)" class="block px-2 py-1 rounded hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer" [style.paddingLeft.rem]="(h.level - 1) * 0.75">{{ h.text }}</a>
<div class="px-4 pb-4 overflow-y-auto max-h-[75vh] sm:max-h-[70vh]" appScrollableOverlay>
<h2 class="sr-only">Sommaire</h2>
<ul class="space-y-1 text-sm text-slate-800 dark:text-slate-200">
<li *ngFor="let h of headings; let i = index">
<button
type="button"
(click)="onGo(h.id)"
class="w-full text-left block px-3 py-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition active:scale-[0.98]"
[style.paddingLeft.rem]="(h.level - 1) * 0.75 + 0.75"
[attr.data-index]="i">
<span class="text-slate-400 dark:text-slate-500 mr-2 text-xs">{{ getLevelIndicator(h.level) }}</span>
<span>{{ h.text }}</span>
</button>
</li>
</ul>
</div>
</div>
`
`,
})
export class AppTocOverlayComponent {
export class AppTocOverlayComponent implements OnInit {
@Input() headings: Array<{ level: number; text: string; id: string }> = [];
@Output() go = new EventEmitter<string>();
@Output() close = new EventEmitter<void>();
onGo(id: string) {
isVisible = false;
private ignoreBackdropUntil = 0;
constructor(private cdr: ChangeDetectorRef) {}
ngOnInit(): void {
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
this.ignoreBackdropUntil = now + 500; // couvre les devices + lents
// Déclenche l’animation d’entrée puis force le rafraîchissement (OnPush)
setTimeout(() => {
this.isVisible = true;
this.cdr.markForCheck();
}, 0);
}
onBackdropPointerDown(ev: PointerEvent): void {
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
if (now < this.ignoreBackdropUntil) {
ev.stopPropagation(); // ignore le premier tap fantôme
return;
}
this.close.emit();
}
onGo(id: string): void {
this.go.emit(id);
}
getLevelIndicator(level: number): string {
return '•'.repeat(Math.min(level, 3));
}
}
@@ -2,33 +2,132 @@ import { Component, EventEmitter, Input, Output, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MobileNavService } from '../../shared/services/mobile-nav.service';
import { FileExplorerComponent } from '../../../components/file-explorer/file-explorer.component';
import type { VaultNode } from '../../../types';
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';
@Component({
selector: 'app-sidebar-drawer',
standalone: true,
imports: [CommonModule, FileExplorerComponent],
imports: [CommonModule, FileExplorerComponent, QuickLinksComponent, ScrollableOverlayDirective],
template: `
<aside class="fixed left-0 top-0 bottom-0 w-80 max-w-[80vw] bg-white dark:bg-gray-900 shadow-lg z-40 transform transition-transform duration-300 ease-in-out"
[class.-translate-x-full]="!mobileNav.sidebarOpen()">
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-800">
<h2 class="text-lg font-semibold">Navigation</h2>
<button (click)="mobileNav.toggleSidebar()" class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800">✕</button>
<aside class="fixed left-0 top-0 bottom-0 w-80 max-w-[80vw] bg-white dark:bg-gray-900 shadow-2xl z-40 transform transition-all duration-300 ease-out flex flex-col"
[class.-translate-x-full]="!mobileNav.sidebarOpen()"
[class.translate-x-0]="mobileNav.sidebarOpen()">
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-800 bg-gradient-to-r from-nimbus-50 to-transparent dark:from-nimbus-900/20">
<h2 class="text-lg font-semibold truncate">{{ vaultName || 'ObsiViewer' }}</h2>
<button
(click)="mobileNav.toggleSidebar()"
class="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-all active:scale-95 transform flex-shrink-0">
✕
</button>
</div>
<div class="flex-1 overflow-y-auto p-2">
<app-file-explorer [nodes]="nodes" [selectedNoteId]="selectedNoteId" [foldersOnly]="true" (folderSelected)="onFolder($event)" (fileSelected)="onSelect($event)"></app-file-explorer>
<!-- Scrollable Content -->
<div class="flex-1 overflow-y-auto min-h-0" appScrollableOverlay>
<!-- Section Tests (dev-only) -->
<section *ngIf="env.features.showTestSection" class="border-b border-gray-200 dark:border-gray-800">
<button class="w-full flex items-center justify-between px-4 py-3 text-sm font-semibold hover:bg-gray-100 dark:hover:bg-gray-800 active:bg-gray-200 dark:active:bg-gray-700 transition-colors"
(click)="open.tests = !open.tests">
<span>Section Tests</span>
<span class="text-xs text-gray-500 transition-transform duration-200" [class.rotate-90]="!open.tests">{{ 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-3 py-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 active:bg-gray-200 dark:active:bg-gray-700 text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 transition-all active:scale-[0.98] transform">
🧪 Markdown Playground
</button>
</div>
</section>
<!-- Quick Links accordion -->
<section class="border-b border-gray-200 dark:border-gray-800">
<button class="w-full flex items-center justify-between px-4 py-3 text-sm font-semibold hover:bg-gray-100 dark:hover:bg-gray-800 active:bg-gray-200 dark:active:bg-gray-700 transition-colors"
(click)="open.quick = !open.quick">
<span>Quick Links</span>
<span class="text-xs text-gray-500 transition-transform duration-200" [class.rotate-90]="!open.quick">{{ open.quick ? '▾' : '▸' }}</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-gray-200 dark:border-gray-800">
<button class="w-full flex items-center justify-between px-4 py-3 text-sm font-semibold hover:bg-gray-100 dark:hover:bg-gray-800 active:bg-gray-200 dark:active:bg-gray-700 transition-colors"
(click)="open.folders = !open.folders">
<span>Folders</span>
<span class="text-xs text-gray-500 transition-transform duration-200" [class.rotate-90]="!open.folders">{{ open.folders ? '▾' : '▸' }}</span>
</button>
<div *ngIf="open.folders" class="px-1 py-1">
<app-file-explorer [nodes]="nodes" [selectedNoteId]="selectedNoteId" [foldersOnly]="true" (folderSelected)="onFolder($event)" (fileSelected)="onSelect($event)"></app-file-explorer>
</div>
</section>
<!-- Tags accordion -->
<section class="border-b border-gray-200 dark:border-gray-800">
<button class="w-full flex items-center justify-between px-4 py-3 text-sm font-semibold hover:bg-gray-100 dark:hover:bg-gray-800 active:bg-gray-200 dark:active:bg-gray-700 transition-colors"
(click)="open.tags = !open.tags">
<span>Tags</span>
<span class="text-xs text-gray-500 transition-transform duration-200" [class.rotate-90]="!open.tags">{{ open.tags ? '▾' : '▸' }}</span>
</button>
<div *ngIf="open.tags" class="px-2 py-2">
<ul class="space-y-1 text-sm">
<li *ngFor="let t of tags" class="flex items-center gap-2">
<button (click)="onTagSelected(t.name)" class="flex-1 text-left px-3 py-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 active:bg-gray-200 dark:active:bg-gray-700 transition-all active:scale-[0.98] transform truncate">
<span>🏷️</span>
<span class="ml-1">{{ t.name }}</span>
</button>
<span class="text-xs text-gray-500 font-medium min-w-[2rem] text-right">{{ t.count }}</span>
</li>
</ul>
</div>
</section>
<!-- Trash accordion -->
<section class="border-b border-gray-200 dark:border-gray-800">
<button class="w-full flex items-center justify-between px-4 py-3 text-sm font-semibold hover:bg-gray-100 dark:hover:bg-gray-800 active:bg-gray-200 dark:active:bg-gray-700 transition-colors"
(click)="open.trash = !open.trash">
<span>Trash</span>
<span class="text-xs text-gray-500 transition-transform duration-200" [class.rotate-90]="!open.trash">{{ open.trash ? '▾' : '▸' }}</span>
</button>
<div *ngIf="open.trash" class="px-3 py-3 text-sm text-gray-500 dark:text-gray-400">Empty</div>
</section>
</div>
<!-- Footer -->
<div class="h-14 border-t border-gray-200 dark:border-gray-800 flex items-center justify-between px-4 text-xs text-gray-500 bg-gray-50 dark:bg-gray-900/50">
<span>ObsiViewer</span>
<span class="text-[10px] opacity-60">v1.0</span>
</div>
</aside>
<div *ngIf="mobileNav.sidebarOpen()" (click)="mobileNav.toggleSidebar()" class="fixed inset-0 bg-black/50 z-30"></div>
<div
*ngIf="mobileNav.sidebarOpen()"
(click)="mobileNav.toggleSidebar()"
class="fixed inset-0 bg-black/50 z-30 transition-opacity duration-300 backdrop-blur-sm"
[class.opacity-0]="!mobileNav.sidebarOpen()"
[class.opacity-100]="mobileNav.sidebarOpen()">
</div>
`
})
export class AppSidebarDrawerComponent {
mobileNav = inject(MobileNavService);
env = environment;
@Input() nodes: VaultNode[] = [];
@Input() selectedNoteId: string | null = null;
@Input() vaultName = '';
@Input() tags: TagInfo[] = [];
@Output() noteSelected = new EventEmitter<string>();
@Output() folderSelected = new EventEmitter<string>();
@Output() tagSelected = new EventEmitter<string>();
@Output() quickLinkSelected = new EventEmitter<string>();
@Output() markdownPlaygroundSelected = new EventEmitter<void>();
open = { quick: true, folders: true, tags: false, trash: false, tests: true };
onSelect(id: string) {
this.noteSelected.emit(id);
@@ -40,4 +139,19 @@ export class AppSidebarDrawerComponent {
this.folderSelected.emit(path);
}
}
onQuickLink(id: string) {
this.quickLinkSelected.emit(id);
this.mobileNav.sidebarOpen.set(false);
}
onTagSelected(tagName: string) {
this.tagSelected.emit(tagName);
this.mobileNav.sidebarOpen.set(false);
}
onMarkdownPlaygroundClick(): void {
this.markdownPlaygroundSelected.emit();
this.mobileNav.sidebarOpen.set(false);
}
}
@@ -16,7 +16,7 @@ import { environment } from '../../../environments/environment';
<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-gray-200 dark:border-gray-800">
<div class="text-sm font-semibold truncate">{{ vaultName }}</div>
<div class="text-sm font-semibold truncate">{{ vaultName }} - ObsiViewer</div>
<button (click)="toggleSidebarRequest.emit()" class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800" title="Hide Sidebar">⟨⟨</button>
</div>