feat: add Nimbus UI mode with adaptive scrollbars and layout improvements
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MobileNavService } from '../../shared/services/mobile-nav.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-bottom-navigation',
|
||||
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">
|
||||
<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>
|
||||
<span class="truncate">{{ tab.label }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
`
|
||||
})
|
||||
export class AppBottomNavigationComponent {
|
||||
mobileNav = inject(MobileNavService);
|
||||
|
||||
tabs = [
|
||||
{ id: 'sidebar', icon: '📁', label: 'Dossiers' },
|
||||
{ id: 'list', icon: '🔍', label: 'Liste' },
|
||||
{ id: 'page', icon: '📄', label: 'Page' },
|
||||
{ id: 'toc', icon: '📋', label: 'Sommaire' }
|
||||
];
|
||||
|
||||
setActiveTab(tabId: 'sidebar' | 'list' | 'page' | 'toc') {
|
||||
this.mobileNav.setActiveTab(tabId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Component, EventEmitter, Output, computed, signal, effect } from '@angular/core';
|
||||
import { input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import type { Note } from '../../../types';
|
||||
import { ScrollableOverlayDirective } from '../../shared/overlay-scrollbar/scrollable-overlay.directive';
|
||||
|
||||
@Component({
|
||||
selector: 'app-notes-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ScrollableOverlayDirective],
|
||||
template: `
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="p-2 border-b border-gray-200 dark:border-gray-800">
|
||||
<input type="text"
|
||||
[value]="query()"
|
||||
(input)="onQuery($any($event.target).value)"
|
||||
placeholder="Rechercher..."
|
||||
class="w-full rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 overflow-y-auto list-scroll" appScrollableOverlay>
|
||||
<ul class="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
<li *ngFor="let n of filtered()" class="p-3 hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer" (click)="openNote.emit(n.id)">
|
||||
<div class="text-sm font-semibold truncate">{{ n.title }}</div>
|
||||
<div class="text-xs text-gray-500 truncate">{{ n.filePath }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-height: 0; /* critical for nested flex scrolling */
|
||||
}
|
||||
|
||||
/* Smooth, bounded vertical scrolling only on the list area */
|
||||
.list-scroll {
|
||||
overscroll-behavior: contain; /* prevent parent scroll chaining */
|
||||
-webkit-overflow-scrolling: touch; /* momentum scrolling on iOS */
|
||||
scroll-behavior: smooth; /* smooth programmatic scrolls */
|
||||
scrollbar-gutter: stable both-edges; /* avoid layout shift when scrollbar shows */
|
||||
max-height: 100%; /* cap to available space within the central section */
|
||||
contain: content; /* small perf win for large lists */
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class NotesListComponent {
|
||||
notes = input<Note[]>([]);
|
||||
folderFilter = input<string | null>(null); // like "folder/subfolder"
|
||||
query = input<string>('');
|
||||
tagFilter = input<string | null>(null);
|
||||
|
||||
@Output() openNote = new EventEmitter<string>();
|
||||
@Output() queryChange = new EventEmitter<string>();
|
||||
|
||||
private q = signal('');
|
||||
private syncQuery = effect(() => {
|
||||
this.q.set(this.query() || '');
|
||||
});
|
||||
|
||||
filtered = computed(() => {
|
||||
const q = (this.q() || '').toLowerCase().trim();
|
||||
const folder = (this.folderFilter() || '').toLowerCase();
|
||||
const tag = (this.tagFilter() || '').toLowerCase();
|
||||
let list = this.notes();
|
||||
|
||||
if (folder) {
|
||||
list = list.filter(n => (n.originalPath || '').toLowerCase().startsWith(folder));
|
||||
}
|
||||
|
||||
if (tag) {
|
||||
list = list.filter(n => Array.isArray(n.tags) && n.tags.some(t => (t || '').toLowerCase() === tag));
|
||||
}
|
||||
|
||||
// Apply query if present
|
||||
if (q) {
|
||||
list = list.filter(n => {
|
||||
const title = (n.title || '').toLowerCase();
|
||||
const filePath = (n.filePath || '').toLowerCase();
|
||||
return title.includes(q) || filePath.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by most recent first (mtime desc; fallback updatedAt/createdAt)
|
||||
const parseDate = (s?: string) => (s ? Date.parse(s) : 0) || 0;
|
||||
const score = (n: Note) => n.mtime || parseDate(n.updatedAt) || parseDate(n.createdAt) || 0;
|
||||
return [...list].sort((a, b) => (score(b) - score(a)));
|
||||
});
|
||||
|
||||
onQuery(v: string) {
|
||||
this.q.set(v);
|
||||
this.queryChange.emit(v);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-toc-overlay',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
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>
|
||||
</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>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class AppTocOverlayComponent {
|
||||
@Input() headings: Array<{ level: number; text: string; id: string }> = [];
|
||||
@Output() go = new EventEmitter<string>();
|
||||
@Output() close = new EventEmitter<void>();
|
||||
|
||||
onGo(id: string) {
|
||||
this.go.emit(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Component, EventEmitter, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-quick-links',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="p-3">
|
||||
<ul class="text-sm">
|
||||
<li><button (click)="select('all')" class="w-full text-left px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center gap-2"><span>🗂️</span> <span>All pages</span></button></li>
|
||||
<li><button (click)="select('favorites')" class="w-full text-left px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center gap-2"><span>❤️</span> <span>Favorites</span></button></li>
|
||||
<li><button (click)="select('templates')" class="w-full text-left px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center gap-2"><span>📑</span> <span>Templates</span></button></li>
|
||||
<li><button (click)="select('import')" class="w-full text-left px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center gap-2"><span>⬇️</span> <span>Import</span></button></li>
|
||||
<li><button (click)="select('tasks')" class="w-full text-left px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center gap-2"><span>🗒️</span> <span>Tasks</span></button></li>
|
||||
<li><button (click)="select('chat')" class="w-full text-left px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center gap-2"><span>💬</span> <span>Chat</span></button></li>
|
||||
<li><button (click)="select('activity')" class="w-full text-left px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center gap-2"><span>🕒</span> <span>Activity Panel</span></button></li>
|
||||
<li><button (click)="select('portal')" class="w-full text-left px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center gap-2"><span>📰</span> <span>Portal</span></button></li>
|
||||
</ul>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class QuickLinksComponent {
|
||||
@Output() quickLinkSelected = new EventEmitter<string>();
|
||||
select(id: string) { this.quickLinkSelected.emit(id); }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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';
|
||||
|
||||
@Component({
|
||||
selector: 'app-sidebar-drawer',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FileExplorerComponent],
|
||||
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>
|
||||
</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>
|
||||
</div>
|
||||
</aside>
|
||||
<div *ngIf="mobileNav.sidebarOpen()" (click)="mobileNav.toggleSidebar()" class="fixed inset-0 bg-black/50 z-30"></div>
|
||||
`
|
||||
})
|
||||
export class AppSidebarDrawerComponent {
|
||||
mobileNav = inject(MobileNavService);
|
||||
|
||||
@Input() nodes: VaultNode[] = [];
|
||||
@Input() selectedNoteId: string | null = null;
|
||||
@Output() noteSelected = new EventEmitter<string>();
|
||||
@Output() folderSelected = new EventEmitter<string>();
|
||||
|
||||
onSelect(id: string) {
|
||||
this.noteSelected.emit(id);
|
||||
this.mobileNav.sidebarOpen.set(false);
|
||||
}
|
||||
|
||||
onFolder(path: string) {
|
||||
if (path) {
|
||||
this.folderSelected.emit(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
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';
|
||||
|
||||
@Component({
|
||||
selector: 'app-nimbus-sidebar',
|
||||
standalone: true,
|
||||
imports: [CommonModule, 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-gray-200 dark:border-gray-800">
|
||||
<div class="text-sm font-semibold truncate">{{ vaultName }}</div>
|
||||
<button (click)="toggleSidebarRequest.emit()" class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800" title="Hide Sidebar">⟨⟨</button>
|
||||
</div>
|
||||
|
||||
<!-- Content (scroll) -->
|
||||
<div class="flex-1 overflow-y-auto min-h-0" appScrollableOverlay>
|
||||
<!-- Quick Links accordion -->
|
||||
<section class="border-b border-gray-200 dark:border-gray-800">
|
||||
<button class="w-full flex items-center justify-between px-3 py-2 text-sm font-semibold hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
(click)="open.quick = !open.quick">
|
||||
<span>Quick Links</span>
|
||||
<span class="text-xs text-gray-500">{{ 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-3 py-2 text-sm font-semibold hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
(click)="open.folders = !open.folders">
|
||||
<span>Folders</span>
|
||||
<span class="text-xs text-gray-500">{{ open.folders ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
<div *ngIf="open.folders" class="px-1 py-1">
|
||||
<app-file-explorer [nodes]="effectiveFileTree" [selectedNoteId]="selectedNoteId" [foldersOnly]="true" (folderSelected)="folderSelected.emit($event)" (fileSelected)="fileSelected.emit($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-3 py-2 text-sm font-semibold hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
(click)="open.tags = !open.tags">
|
||||
<span>Tags</span>
|
||||
<span class="text-xs text-gray-500">{{ open.tags ? '▾' : '▸' }}</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 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 truncate">
|
||||
<span>🏷️</span>
|
||||
<span class="ml-1">{{ t.name }}</span>
|
||||
</button>
|
||||
<span class="text-xs text-gray-500">{{ 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-3 py-2 text-sm font-semibold hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
(click)="open.trash = !open.trash">
|
||||
<span>Trash</span>
|
||||
<span class="text-xs text-gray-500">{{ 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 placeholder -->
|
||||
<div class="h-14 border-t border-gray-200 dark:border-gray-800 flex items-center px-3 text-xs text-gray-500">ObsiViewer</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class NimbusSidebarComponent {
|
||||
@Input() vaultName = '';
|
||||
@Input() effectiveFileTree: VaultNode[] = [];
|
||||
@Input() selectedNoteId: string | null = null;
|
||||
@Input() tags: TagInfo[] = [];
|
||||
|
||||
@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>();
|
||||
|
||||
open = { quick: true, folders: true, tags: false, trash: false };
|
||||
|
||||
onQuickLink(id: string) { this.quickLinkSelected.emit(id); }
|
||||
}
|
||||
Reference in New Issue
Block a user