feat: add Nimbus UI mode with adaptive scrollbars and layout improvements

This commit is contained in:
Bruno Charest 2025-10-15 11:57:32 -04:00
parent 5a95b33081
commit 55a7a06daa
29 changed files with 1669 additions and 36 deletions

View File

@ -30,7 +30,13 @@
}, },
"configurations": { "configurations": {
"production": { "production": {
"outputHashing": "all" "outputHashing": "all",
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
]
}, },
"development": { "development": {
"optimization": false, "optimization": false,

View File

@ -25,5 +25,26 @@
</head> </head>
<body> <body>
<app-root></app-root> <app-root></app-root>
<script>
(function () {
const body = document.body;
let timer = null;
const IDLE_DELAY = 900;
const setIdle = () => {
body.classList.remove('scrollbar-moving');
body.classList.add('scrollbar-idle');
};
const setMoving = () => {
body.classList.add('scrollbar-moving');
body.classList.remove('scrollbar-idle');
if (timer) clearTimeout(timer);
timer = setTimeout(setIdle, IDLE_DELAY);
};
setIdle();
window.addEventListener('mousemove', setMoving, { passive: true });
window.addEventListener('wheel', setMoving, { passive: true });
window.addEventListener('touchmove', setMoving, { passive: true });
})();
</script>
</body> </body>
</html> </html>

View File

@ -6,6 +6,7 @@
"scripts": { "scripts": {
"dev": "ng serve", "dev": "ng serve",
"build": "ng build", "build": "ng build",
"prod": "ng build --configuration=production",
"build:workers": "ng build", "build:workers": "ng build",
"preview": "ng serve --configuration=production --port 3000 --host 127.0.0.1", "preview": "ng serve --configuration=production --port 3000 --host 127.0.0.1",
"test": "ng test", "test": "ng test",

View File

@ -1,4 +1,32 @@
<!-- ObsiViewer - Application optimisée pour mobile et desktop --> <!-- ObsiViewer - Application optimisée pour mobile et desktop -->
@if (uiMode.isNimbusMode()) {
<app-shell-nimbus-layout
[vaultName]="vaultName()"
[effectiveFileTree]="effectiveFileTree()"
[selectedNoteId]="selectedNoteId()"
[selectedNote]="selectedNote()"
[renderedNoteContent]="renderedNoteContent()"
[tableOfContents]="tableOfContents()"
[isSidebarOpen]="isSidebarOpen()"
[isOutlineOpen]="isOutlineOpen()"
[leftSidebarWidth]="leftSidebarWidth()"
[rightSidebarWidth]="rightSidebarWidth()"
[centerPanelWidth]="centerPanelWidth()"
[searchTerm]="sidebarSearchTerm()"
[tags]="allTags()"
(noteSelected)="selectNote($event)"
(tagClicked)="handleTagClick($event)"
(wikiLinkActivated)="handleWikiLink($event)"
(leftResizeStart)="startLeftResize($event)"
(rightResizeStart)="startRightResize($event)"
(centerResizeStart)="startCenterResize($event)"
(toggleSidebarRequest)="toggleSidebar()"
(toggleOutlineRequest)="toggleOutline()"
(navigateHeading)="scrollToHeading($event)"
(searchTermChange)="onSidebarSearchTermChange($event)"
(searchOptionsChange)="onHeaderSearchOptionsChange($event)"
></app-shell-nimbus-layout>
} @else {
<main class="relative flex min-h-screen flex-col bg-bg-main text-text-main lg:flex-row lg:h-screen lg:overflow-hidden"> <main class="relative flex min-h-screen flex-col bg-bg-main text-text-main lg:flex-row lg:h-screen lg:overflow-hidden">
@if (isRawViewOpen()) { @if (isRawViewOpen()) {
<app-raw-view-overlay <app-raw-view-overlay
@ -364,6 +392,15 @@
<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> <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>
<button
type="button"
class="btn btn-ghost rounded px-2 py-1 text-xs font-semibold"
(click)="toggleUIMode()"
[attr.aria-label]="'Basculer vers ' + (uiMode.isNimbusMode() ? 'ancienne' : 'nouvelle') + ' interface'"
title="Basculer d'interface"
>
@if (uiMode.isNimbusMode()) { 🔧 Legacy } @else { ✨ Nimbus }
</button>
<button <button
(click)="toggleOutline()" (click)="toggleOutline()"
class="btn btn-icon btn-ghost" class="btn btn-icon btn-ghost"
@ -435,6 +472,15 @@
<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> <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>
<button
type="button"
class="btn btn-ghost rounded px-2 py-1 text-xs font-semibold"
(click)="toggleUIMode()"
[attr.aria-label]="'Basculer vers ' + (uiMode.isNimbusMode() ? 'ancienne' : 'nouvelle') + ' interface'"
title="Basculer d'interface"
>
@if (uiMode.isNimbusMode()) { 🔧 Legacy } @else { ✨ Nimbus }
</button>
<button <button
(click)="toggleOutline()" (click)="toggleOutline()"
class="btn btn-icon btn-ghost" class="btn btn-icon btn-ghost"
@ -612,4 +658,5 @@
</aside> </aside>
} }
</main> </main>
}

View File

@ -9,6 +9,7 @@ import { MarkdownViewerService } from './services/markdown-viewer.service';
import { DownloadService } from './core/services/download.service'; import { DownloadService } from './core/services/download.service';
import { ThemeService } from './app/core/services/theme.service'; import { ThemeService } from './app/core/services/theme.service';
import { LogService } from './core/logging/log.service'; import { LogService } from './core/logging/log.service';
import { UiModeService } from './app/shared/services/ui-mode.service';
// Components // Components
import { FileExplorerComponent } from './components/file-explorer/file-explorer.component'; import { FileExplorerComponent } from './components/file-explorer/file-explorer.component';
@ -19,6 +20,7 @@ import { MarkdownCalendarComponent } from './components/markdown-calendar/markdo
import { GraphInlineSettingsComponent } from './app/graph/ui/inline-settings-panel.component'; import { GraphInlineSettingsComponent } from './app/graph/ui/inline-settings-panel.component';
import { DrawingsEditorComponent } from './app/features/drawings/drawings-editor.component'; import { DrawingsEditorComponent } from './app/features/drawings/drawings-editor.component';
import { DrawingsFileService, ExcalidrawScene } from './app/features/drawings/drawings-file.service'; import { DrawingsFileService, ExcalidrawScene } from './app/features/drawings/drawings-file.service';
import { AppShellNimbusLayoutComponent } from './app/layout/app-shell-nimbus/app-shell-nimbus.component';
import { RawViewOverlayComponent } from './shared/overlays/raw-view-overlay.component'; import { RawViewOverlayComponent } from './shared/overlays/raw-view-overlay.component';
import { BookmarksPanelComponent } from './components/bookmarks-panel/bookmarks-panel.component'; import { BookmarksPanelComponent } from './components/bookmarks-panel/bookmarks-panel.component';
import { AddBookmarkModalComponent, type BookmarkFormData, type BookmarkDeleteEvent } from './components/add-bookmark-modal/add-bookmark-modal.component'; import { AddBookmarkModalComponent, type BookmarkFormData, type BookmarkDeleteEvent } from './components/add-bookmark-modal/add-bookmark-modal.component';
@ -29,6 +31,7 @@ import { SearchHistoryService } from './core/search/search-history.service';
import { GraphIndexService } from './core/graph/graph-index.service'; import { GraphIndexService } from './core/graph/graph-index.service';
import { SearchIndexService } from './core/search/search-index.service'; import { SearchIndexService } from './core/search/search-index.service';
import { SearchOrchestratorService } from './core/search/search-orchestrator.service'; import { SearchOrchestratorService } from './core/search/search-orchestrator.service';
import { LayoutModule } from '@angular/cdk/layout';
// Types // Types
import { FileMetadata, Note, TagInfo, VaultNode } from './types'; import { FileMetadata, Note, TagInfo, VaultNode } from './types';
@ -42,6 +45,7 @@ interface TocEntry {
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [ imports: [
LayoutModule,
CommonModule, CommonModule,
FormsModule, FormsModule,
FileExplorerComponent, FileExplorerComponent,
@ -56,6 +60,7 @@ interface TocEntry {
SearchInputWithAssistantComponent, SearchInputWithAssistantComponent,
SearchPanelComponent, SearchPanelComponent,
DrawingsEditorComponent, DrawingsEditorComponent,
AppShellNimbusLayoutComponent,
], ],
templateUrl: './app.component.simple.html', templateUrl: './app.component.simple.html',
styleUrls: ['./app.component.css'], styleUrls: ['./app.component.css'],
@ -67,6 +72,7 @@ export class AppComponent implements OnInit, OnDestroy {
private markdownViewerService = inject(MarkdownViewerService); private markdownViewerService = inject(MarkdownViewerService);
private downloadService = inject(DownloadService); private downloadService = inject(DownloadService);
private readonly themeService = inject(ThemeService); private readonly themeService = inject(ThemeService);
readonly uiMode = inject(UiModeService);
private readonly bookmarksService = inject(BookmarksService); private readonly bookmarksService = inject(BookmarksService);
private readonly searchHistoryService = inject(SearchHistoryService); private readonly searchHistoryService = inject(SearchHistoryService);
private readonly graphIndexService = inject(GraphIndexService); private readonly graphIndexService = inject(GraphIndexService);
@ -87,6 +93,7 @@ export class AppComponent implements OnInit, OnDestroy {
tableOfContents = signal<TocEntry[]>([]); tableOfContents = signal<TocEntry[]>([]);
leftSidebarWidth = signal<number>(288); leftSidebarWidth = signal<number>(288);
rightSidebarWidth = signal<number>(288); rightSidebarWidth = signal<number>(288);
centerPanelWidth = signal<number>(384);
isRawViewOpen = signal<boolean>(false); isRawViewOpen = signal<boolean>(false);
isRawViewWrapped = signal<boolean>(true); isRawViewWrapped = signal<boolean>(true);
showAddBookmarkModal = signal<boolean>(false); showAddBookmarkModal = signal<boolean>(false);
@ -94,6 +101,8 @@ export class AppComponent implements OnInit, OnDestroy {
readonly LEFT_MAX_WIDTH = 520; readonly LEFT_MAX_WIDTH = 520;
readonly RIGHT_MIN_WIDTH = 220; readonly RIGHT_MIN_WIDTH = 220;
readonly RIGHT_MAX_WIDTH = 520; readonly RIGHT_MAX_WIDTH = 520;
readonly CENTER_MIN_WIDTH = 260;
readonly CENTER_MAX_WIDTH = 640;
private rawViewTriggerElement: HTMLElement | null = null; private rawViewTriggerElement: HTMLElement | null = null;
private viewportWidth = signal<number>(typeof window !== 'undefined' ? window.innerWidth : 0); private viewportWidth = signal<number>(typeof window !== 'undefined' ? window.innerWidth : 0);
private resizeHandler = () => { private resizeHandler = () => {
@ -101,6 +110,7 @@ export class AppComponent implements OnInit, OnDestroy {
this.viewportWidth.set(window.innerWidth); this.viewportWidth.set(window.innerWidth);
}; };
// --- Search cross-UI sync --- // --- Search cross-UI sync ---
onHeaderSearchOptionsChange(opts: { caseSensitive: boolean; regexMode: boolean; highlight: boolean }): void { onHeaderSearchOptionsChange(opts: { caseSensitive: boolean; regexMode: boolean; highlight: boolean }): void {
this.lastCaseSensitive.set(!!opts.caseSensitive); this.lastCaseSensitive.set(!!opts.caseSensitive);
@ -109,6 +119,37 @@ export class AppComponent implements OnInit, OnDestroy {
this.scheduleApplyDocumentHighlight(); this.scheduleApplyDocumentHighlight();
} }
startCenterResize(event: PointerEvent): void {
event.preventDefault();
const handle = event.currentTarget as HTMLElement | null;
handle?.setPointerCapture(event.pointerId);
const startX = event.clientX;
const startWidth = this.centerPanelWidth();
const moveHandler = (moveEvent: PointerEvent) => {
const delta = moveEvent.clientX - startX;
let newWidth = startWidth + delta;
newWidth = Math.max(this.CENTER_MIN_WIDTH, Math.min(this.CENTER_MAX_WIDTH, newWidth));
this.centerPanelWidth.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);
}
onFileNodeSelected(noteId: string): void { onFileNodeSelected(noteId: string): void {
if (!noteId) return; if (!noteId) return;
const meta = this.vaultService.getFastMetaById(noteId); const meta = this.vaultService.getFastMetaById(noteId);
@ -632,6 +673,10 @@ export class AppComponent implements OnInit, OnDestroy {
this.themeService.toggleTheme(); this.themeService.toggleTheme();
} }
toggleUIMode(): void {
this.uiMode.toggleUIMode();
}
toggleSidebar(): void { toggleSidebar(): void {
this.isSidebarOpen.update(value => !value); this.isSidebarOpen.update(value => !value);
} }

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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); }
}

View File

@ -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);
}
}
}

View File

@ -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); }
}

View File

@ -0,0 +1,306 @@
import { Component, EventEmitter, Input, Output, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { UiModeService } from '../../shared/services/ui-mode.service';
import { ResponsiveService } from '../../shared/services/responsive.service';
import { MobileNavService } from '../../shared/services/mobile-nav.service';
import { FileExplorerComponent } from '../../../components/file-explorer/file-explorer.component';
import { NoteViewerComponent } from '../../../components/tags-view/note-viewer/note-viewer.component';
import { VaultService } from '../../../services/vault.service';
import type { VaultNode, Note, TagInfo } from '../../../types';
import { AppBottomNavigationComponent } from '../../features/bottom-nav/app-bottom-navigation.component';
import { AppSidebarDrawerComponent } from '../../features/sidebar/app-sidebar-drawer.component';
import { AppTocOverlayComponent } from '../../features/note-view/app-toc-overlay.component';
import { SwipeNavDirective } from '../../shared/directives/swipe-nav.directive';
import { NotesListComponent } from '../../features/list/notes-list.component';
import { NimbusSidebarComponent } from '../../features/sidebar/nimbus-sidebar.component';
import { QuickLinksComponent } from '../../features/quick-links/quick-links.component';
import { ScrollableOverlayDirective } from '../../shared/overlay-scrollbar/scrollable-overlay.directive';
@Component({
selector: 'app-shell-nimbus-layout',
standalone: true,
imports: [CommonModule, FileExplorerComponent, NoteViewerComponent, AppBottomNavigationComponent, AppSidebarDrawerComponent, AppTocOverlayComponent, SwipeNavDirective, NotesListComponent, NimbusSidebarComponent, QuickLinksComponent, ScrollableOverlayDirective],
template: `
<div class="h-screen flex flex-col bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
<!-- Header (desktop/tablet), compact on mobile) -->
<header class="flex items-center justify-between px-4 border-b border-gray-200 dark:border-gray-800 bg-white/90 dark:bg-gray-900/90 backdrop-blur-sm" [class.h-14]="responsive.isDesktop() || responsive.isTablet()" [class.h-12]="responsive.isMobile()">
<div class="flex items-center gap-2 min-w-0">
<button *ngIf="responsive.isMobile()" (click)="mobileNav.toggleSidebar()" class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800"></button>
<span class="inline-flex items-center rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 px-2.5 py-1 text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300 truncate">{{ vaultName }}</span>
<h1 class="hidden sm:block text-base font-semibold truncate">ObsiViewer · Nimbus</h1>
</div>
<div class="flex items-center gap-2">
<button class="hidden lg:inline-flex p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800" (click)="toggleSidebarRequest.emit()" [attr.aria-expanded]="isSidebarOpen" title="Basculer la barre latérale gauche">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/><path d="m16 15-3-3 3-3"/></svg>
</button>
<button class="hidden xl:inline-flex p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800" (click)="toggleOutlineRequest.emit()" [attr.aria-expanded]="isOutlineOpen" title="Basculer la barre latérale droite">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>
<button (click)="ui.toggleUIMode()" class="px-2 py-1 rounded text-xs font-semibold border border-gray-300 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800">
{{ ui.isNimbusMode() ? '🔧 Legacy' : '✨ Nimbus' }}
</button>
</div>
</header>
<!-- Desktop 3-column layout -->
<div *ngIf="responsive.isDesktop()" class="flex-1 flex overflow-hidden relative">
<!-- Left: full sidebar or collapsed rail -->
<ng-container *ngIf="isSidebarOpen; else collapsedRail">
<aside class="flex flex-col border-r border-gray-200 dark:border-gray-800 min-h-0" [style.width.px]="leftSidebarWidth">
<app-nimbus-sidebar
[vaultName]="vaultName"
[effectiveFileTree]="effectiveFileTree"
[selectedNoteId]="selectedNoteId"
[tags]="tags"
(toggleSidebarRequest)="toggleSidebarRequest.emit()"
(folderSelected)="onFolderSelected($event)"
(fileSelected)="noteSelected.emit($event)"
(tagSelected)="onTagSelected($event)"
(quickLinkSelected)="onQuickLink($event)"
/>
</aside>
</ng-container>
<ng-template #collapsedRail>
<aside class="border-r border-gray-200 dark:border-gray-800 h-full w-14 flex flex-col items-center py-3 gap-3">
<button class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800" (click)="toggleSidebarRequest.emit()" title="Show Sidebar"></button>
<button class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800" (mouseenter)="openFlyout('quick')" (mouseleave)="scheduleCloseFlyout()" title="Quick Links"></button>
<button class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800" (mouseenter)="openFlyout('folders')" (mouseleave)="scheduleCloseFlyout()" title="Folders">📁</button>
<button class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800" (mouseenter)="openFlyout('tags')" (mouseleave)="scheduleCloseFlyout()" title="Tags">🏷</button>
<button class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800" (mouseenter)="openFlyout('trash')" (mouseleave)="scheduleCloseFlyout()" title="Trash">🗑</button>
</aside>
<!-- Flyouts -->
<div class="absolute left-14 top-0 bottom-0 w-80 max-w-[70vw] bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 shadow-xl" *ngIf="hoveredFlyout as f" (mouseenter)="cancelCloseFlyout()" (mouseleave)="scheduleCloseFlyout()">
<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">{{ f === 'quick' ? 'Quick Links' : (f === 'folders' ? 'Folders' : (f === 'tags' ? 'Tags' : 'Trash')) }}</div>
<button class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800" (click)="hoveredFlyout=null"></button>
</div>
<div class="h-[calc(100%-3rem)] overflow-y-auto" appScrollableOverlay>
<ng-container [ngSwitch]="f">
<app-quick-links *ngSwitchCase="'quick'" (quickLinkSelected)="onQuickLink($event)"></app-quick-links>
<div *ngSwitchCase="'folders'" class="p-2">
<app-file-explorer [nodes]="effectiveFileTree" [selectedNoteId]="selectedNoteId" [foldersOnly]="true" (folderSelected)="onFolderSelected($event)" (fileSelected)="noteSelected.emit($event)"></app-file-explorer>
</div>
<div *ngSwitchCase="'tags'" class="p-2">
<ul class="space-y-0.5 text-sm">
<li *ngFor="let t of tags">
<button (click)="onTagSelected(t.name)" class="w-full text-left px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 truncate">🏷 {{ t.name }} <span class="text-xs text-gray-500">{{ t.count }}</span></button>
</li>
</ul>
</div>
<div *ngSwitchDefault class="p-3 text-sm text-gray-500">Empty</div>
</ng-container>
</div>
</div>
</ng-template>
<!-- Left Resizer -->
<div class="h-full w-1 cursor-col-resize hover:bg-gray-200/50 dark:hover:bg-gray-700/50" (pointerdown)="leftResizeStart.emit($event)" role="separator" aria-orientation="vertical" aria-label="Redimensionner la barre latérale gauche"></div>
<!-- Center List -->
<section class="border-r border-gray-200 dark:border-gray-800 overflow-hidden" [style.width.px]="centerPanelWidth">
<div class="h-full flex flex-col">
<app-notes-list class="flex-1"
[notes]="vault.allNotes()"
[folderFilter]="folderFilter"
[tagFilter]="tagFilter"
[query]="listQuery"
(openNote)="onOpenNote($event)"
(queryChange)="listQuery = $event"
/>
</div>
</section>
<!-- Center Resizer (between list and note) -->
<div class="h-full w-1 cursor-col-resize hover:bg-gray-200/50 dark:hover:bg-gray-700/50" (pointerdown)="centerResizeStart.emit($event)" role="separator" aria-orientation="vertical" aria-label="Redimensionner la zone de liste"></div>
<!-- Note View + ToC -->
<section class="flex-1 relative min-w-0 flex">
<div class="note-content-area flex-1 overflow-y-auto px-4 py-4 lg:px-8" appScrollableOverlay>
<app-note-viewer
[note]="selectedNote || null"
[noteHtmlContent]="renderedNoteContent"
[allNotes]="vault.allNotes()"
(noteLinkClicked)="noteSelected.emit($event)"
(tagClicked)="tagClicked.emit($event)"
(wikiLinkActivated)="wikiLinkActivated.emit($event)"
></app-note-viewer>
</div>
<aside class="hidden xl:block border-l border-gray-200 dark:border-gray-800 overflow-y-auto" appScrollableOverlay [style.width.px]="isOutlineOpen ? rightSidebarWidth : 0" [class.opacity-0]="!isOutlineOpen" [class.pointer-events-none]="!isOutlineOpen">
<div class="p-3">
<h2 class="text-sm font-semibold mb-2">Sommaire</h2>
<ul class="space-y-1 text-sm text-gray-600 dark:text-gray-300">
<li *ngFor="let h of tableOfContents">
<a class="block truncate hover:text-gray-900 dark:hover:text-white cursor-pointer" (click)="navigateHeading.emit(h.id)" [style.paddingLeft.rem]="(h.level - 1) * 0.75">{{ h.text }}</a>
</li>
</ul>
</div>
</aside>
</section>
</div>
<!-- Tablet: simple tabbed areas -->
<div *ngIf="responsive.isTablet()" class="flex-1 flex flex-col overflow-hidden">
<div class="h-12 border-b border-gray-200 dark:border-gray-800 flex items-center">
<button class="flex-1 h-full text-sm" [class.text-nimbus-500]="mobileNav.activeTab() === 'sidebar'" (click)="mobileNav.setActiveTab('sidebar')">Sidebar</button>
<button class="flex-1 h-full text-sm" [class.text-nimbus-500]="mobileNav.activeTab() === 'list'" (click)="mobileNav.setActiveTab('list')">Liste</button>
<button class="flex-1 h-full text-sm" [class.text-nimbus-500]="mobileNav.activeTab() === 'page'" (click)="mobileNav.setActiveTab('page')">Page</button>
</div>
<div class="flex-1 overflow-hidden">
<div [hidden]="mobileNav.activeTab() !== 'sidebar'" class="h-full overflow-y-auto p-2" appScrollableOverlay>
<app-file-explorer [nodes]="effectiveFileTree" [selectedNoteId]="selectedNoteId" [foldersOnly]="true" (folderSelected)="onFolderSelected($event)" (fileSelected)="onOpenNote($event)"></app-file-explorer>
</div>
<div [hidden]="mobileNav.activeTab() !== 'list'" class="h-full overflow-y-auto" appScrollableOverlay>
<app-notes-list [notes]="vault.allNotes()" [folderFilter]="folderFilter" [tagFilter]="tagFilter" [query]="listQuery" (queryChange)="listQuery=$event" (openNote)="onOpenNote($event)"></app-notes-list>
</div>
<div [hidden]="mobileNav.activeTab() !== 'page'" class="h-full overflow-y-auto px-3 py-4" appScrollableOverlay>
<app-note-viewer [note]="selectedNote || null" [noteHtmlContent]="renderedNoteContent" [allNotes]="vault.allNotes()" (noteLinkClicked)="noteSelected.emit($event)" (tagClicked)="tagClicked.emit($event)" (wikiLinkActivated)="wikiLinkActivated.emit($event)"></app-note-viewer>
</div>
</div>
</div>
<!-- Mobile: bottom nav + drawer + swipe -->
<div *ngIf="responsive.isMobile()" class="flex-1 relative overflow-hidden" appSwipeNav (swipeLeft)="nextTab()" (swipeRight)="prevTab()">
<app-sidebar-drawer [nodes]="effectiveFileTree" [selectedNoteId]="selectedNoteId" (noteSelected)="onNoteSelectedMobile($event)" (folderSelected)="onFolderSelectedFromDrawer($event)"></app-sidebar-drawer>
<div [hidden]="mobileNav.activeTab() !== 'list'" class="h-full flex flex-col overflow-hidden">
<app-notes-list class="flex-1" [notes]="vault.allNotes()" [folderFilter]="folderFilter" [tagFilter]="tagFilter" [query]="listQuery" (queryChange)="listQuery=$event" (openNote)="onNoteSelectedMobile($event)"></app-notes-list>
</div>
<div [hidden]="mobileNav.activeTab() !== 'page'" class="h-full overflow-y-auto px-3 py-3" appScrollableOverlay>
<div class="flex items-center justify-between mb-2">
<h2 class="text-base font-semibold truncate">{{ selectedNote?.title || 'Aucune page' }}</h2>
<button *ngIf="tableOfContents.length > 0" (click)="mobileNav.toggleToc()" class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800">📋</button>
</div>
<app-note-viewer [note]="selectedNote || null" [noteHtmlContent]="renderedNoteContent" [allNotes]="vault.allNotes()" (noteLinkClicked)="noteSelected.emit($event)" (tagClicked)="tagClicked.emit($event)" (wikiLinkActivated)="wikiLinkActivated.emit($event)"></app-note-viewer>
</div>
<app-toc-overlay *ngIf="mobileNav.tocOpen()" [headings]="tableOfContents" (go)="navigateHeading.emit($event); mobileNav.toggleToc()" (close)="mobileNav.toggleToc()"></app-toc-overlay>
<app-bottom-navigation></app-bottom-navigation>
</div>
</div>
`
})
export class AppShellNimbusLayoutComponent {
ui = inject(UiModeService);
vault = inject(VaultService);
responsive = inject(ResponsiveService);
mobileNav = inject(MobileNavService);
@Input() vaultName = '';
@Input() effectiveFileTree: VaultNode[] = [];
@Input() selectedNoteId: string | null = '';
@Input() selectedNote: Note | undefined;
@Input() renderedNoteContent = '';
@Input() tableOfContents: Array<{ level: number; text: string; id: string }> = [];
@Input() isSidebarOpen = true;
@Input() isOutlineOpen = true;
@Input() leftSidebarWidth = 288;
@Input() rightSidebarWidth = 288;
@Input() searchTerm = '';
@Input() centerPanelWidth = 384;
@Input() tags: TagInfo[] = [];
@Output() noteSelected = new EventEmitter<string>();
@Output() tagClicked = new EventEmitter<string>();
@Output() wikiLinkActivated = new EventEmitter<any>();
@Output() toggleSidebarRequest = new EventEmitter<void>();
@Output() toggleOutlineRequest = new EventEmitter<void>();
@Output() leftResizeStart = new EventEmitter<PointerEvent>();
@Output() rightResizeStart = new EventEmitter<PointerEvent>();
@Output() centerResizeStart = new EventEmitter<PointerEvent>();
@Output() navigateHeading = new EventEmitter<string>();
@Output() searchTermChange = new EventEmitter<string>();
@Output() searchOptionsChange = new EventEmitter<any>();
folderFilter: string | null = null;
listQuery: string = '';
hoveredFlyout: 'quick' | 'folders' | 'tags' | 'trash' | null = null;
private flyoutCloseTimer: any = null;
tagFilter: string | null = null;
nextTab() {
const order: Array<'sidebar' | 'list' | 'page' | 'toc'> = ['sidebar', 'list', 'page', 'toc'];
const idx = order.indexOf(this.mobileNav.activeTab());
const next = order[Math.min(order.length - 1, idx + 1)] as any;
this.mobileNav.setActiveTab(next);
}
prevTab() {
const order: Array<'sidebar' | 'list' | 'page' | 'toc'> = ['sidebar', 'list', 'page', 'toc'];
const idx = order.indexOf(this.mobileNav.activeTab());
const prev = order[Math.max(0, idx - 1)] as any;
this.mobileNav.setActiveTab(prev);
}
onOpenNote(noteId: string) {
this.noteSelected.emit(noteId);
}
onNoteSelectedMobile(noteId: string) {
if (!noteId) return;
this.noteSelected.emit(noteId);
this.mobileNav.setActiveTab('page');
}
onFolderSelected(path: string) {
this.folderFilter = path || null;
this.tagFilter = null; // clear tag when focusing folder
if (this.responsive.isMobile() || this.responsive.isTablet()) {
this.mobileNav.setActiveTab('list');
}
}
onFolderSelectedFromDrawer(path: string) {
this.folderFilter = path || null;
this.mobileNav.setActiveTab('list');
this.mobileNav.sidebarOpen.set(false);
}
onQuickLink(_id: string) {
if (_id === 'all') {
// Show all pages: clear filters and focus list
this.folderFilter = null;
this.tagFilter = null;
this.listQuery = '';
if (!this.responsive.isDesktop()) {
this.mobileNav.setActiveTab('list');
}
// If flyout is open, keep it or close? Close gracefully
this.scheduleCloseFlyout(150);
}
}
onTagSelected(tagName: string) {
const norm = (tagName || '').replace(/^#/, '').trim();
if (!norm) return;
this.tagFilter = norm;
this.folderFilter = null; // clear folder when focusing tag
if (!this.responsive.isDesktop()) {
this.mobileNav.setActiveTab('list');
}
// If from flyout, do not close immediately; small delay allows click feedback
this.scheduleCloseFlyout(200);
}
openFlyout(which: 'quick' | 'folders' | 'tags' | 'trash') {
this.cancelCloseFlyout();
this.hoveredFlyout = which;
}
scheduleCloseFlyout(delay = 200) {
this.cancelCloseFlyout();
this.flyoutCloseTimer = setTimeout(() => {
this.hoveredFlyout = null;
this.flyoutCloseTimer = null;
}, delay);
}
cancelCloseFlyout() {
if (this.flyoutCloseTimer) {
clearTimeout(this.flyoutCloseTimer);
this.flyoutCloseTimer = null;
}
}
}

View File

@ -0,0 +1,91 @@
import { Directive, ElementRef, NgZone, OnDestroy } from '@angular/core';
import { signal } from '@angular/core';
type ScrollState = 'idle' | 'mini' | 'active';
@Directive({
selector: '[appAdaptiveScrollbar]',
standalone: true,
host: {
class: 'adaptive-scrollbar',
'[attr.data-scroll-state]': 'state()'
}
})
export class AdaptiveScrollbarDirective implements OnDestroy {
readonly state = signal<ScrollState>('idle');
private idleDelay = 1500;
private edgeThreshold = 16;
private idleTimer: any = null;
private cleanup: Array<() => void> = [];
constructor(private el: ElementRef<HTMLElement>, private zone: NgZone) {
this.zone.runOutsideAngular(() => {
const element = this.el.nativeElement;
const onMouseEnter = () => this.transitionTo('mini');
const onMouseLeave = () => this.scheduleIdle();
const onMouseMove = (event: MouseEvent) => this.handlePointerMove(event.clientX, event.clientY);
const onTouchMove = () => this.transitionTo('mini');
const onWheel = () => this.transitionTo('mini');
const onScroll = () => this.transitionTo('mini');
this.register(element, 'mouseenter', onMouseEnter, { passive: true });
this.register(element, 'mouseleave', onMouseLeave, { passive: true });
this.register(element, 'mousemove', onMouseMove, { passive: true });
this.register(element, 'touchstart', onTouchMove, { passive: true });
this.register(element, 'touchmove', onTouchMove, { passive: true });
this.register(element, 'wheel', onWheel, { passive: true });
this.register(element, 'scroll', onScroll, { passive: true });
});
}
ngOnDestroy(): void {
this.clearIdleTimer();
for (const dispose of this.cleanup) {
try { dispose(); } catch { /* ignore */ }
}
this.cleanup = [];
}
private transitionTo(target: ScrollState): void {
this.clearIdleTimer();
if (this.state() !== target) {
this.state.set(target);
}
if (target !== 'idle') {
this.scheduleIdle();
}
}
private handlePointerMove(clientX: number, clientY: number): void {
const rect = this.el.nativeElement.getBoundingClientRect();
const nearRightEdge = clientX >= rect.right - this.edgeThreshold && clientX <= rect.right + 4;
const withinVerticalBounds = clientY >= rect.top && clientY <= rect.bottom;
if (nearRightEdge && withinVerticalBounds) {
this.transitionTo('active');
} else {
this.transitionTo('mini');
}
}
private scheduleIdle(): void {
this.clearIdleTimer();
this.idleTimer = setTimeout(() => {
this.state.set('idle');
this.idleTimer = null;
}, this.idleDelay);
}
private clearIdleTimer(): void {
if (this.idleTimer) {
clearTimeout(this.idleTimer);
this.idleTimer = null;
}
}
private register<T extends Event>(element: HTMLElement, type: string, handler: (event: any) => void, options?: AddEventListenerOptions): void {
element.addEventListener(type, handler as any, options);
this.cleanup.push(() => element.removeEventListener(type, handler as any, options));
}
}

View File

@ -0,0 +1,36 @@
import { Directive, ElementRef, Input, OnInit } from '@angular/core';
@Directive({
selector: 'img[appLazyLoad]',
standalone: true,
})
export class LazyLoadDirective implements OnInit {
@Input() src!: string;
@Input() alt = '';
constructor(private el: ElementRef<HTMLImageElement>) {}
ngOnInit() {
const img = this.el.nativeElement;
const set = () => {
if (!this.src) return;
img.src = this.src;
if (this.alt) img.alt = this.alt;
};
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
set();
observer.unobserve(img);
}
});
});
observer.observe(img);
} else {
// Fallback
set();
}
}
}

View File

@ -0,0 +1,32 @@
import { Directive, Output, EventEmitter, HostListener } from '@angular/core';
@Directive({
selector: '[appSwipeNav]',
standalone: true,
})
export class SwipeNavDirective {
@Output() swipeLeft = new EventEmitter<void>();
@Output() swipeRight = new EventEmitter<void>();
private startX = 0;
private startY = 0;
private threshold = 50; // min horizontal distance
private restraint = 100; // max vertical deflection
@HostListener('touchstart', ['$event'])
onTouchStart(e: TouchEvent) {
const t = e.touches[0];
this.startX = t.clientX;
this.startY = t.clientY;
}
@HostListener('touchend', ['$event'])
onTouchEnd(e: TouchEvent) {
const t = e.changedTouches[0];
const dx = this.startX - t.clientX;
const dy = Math.abs(this.startY - t.clientY);
if (Math.abs(dx) >= this.threshold && dy <= this.restraint) {
if (dx > 0) this.swipeLeft.emit(); else this.swipeRight.emit();
}
}
}

View File

@ -0,0 +1,10 @@
<div class="ovsb__root" [class.ovsb--hidden]="state==='hidden'" [class.ovsb--mini]="state==='mini'" [class.ovsb--active]="state==='active'" [style.right.px]="right">
<div class="ovsb__track" aria-hidden="true"></div>
<div
class="ovsb__thumb"
role="scrollbar"
[attr.aria-controls]="ariaControls || null"
aria-orientation="vertical"
tabindex="0"
></div>
</div>

View File

@ -0,0 +1,63 @@
.ovsb {
position: absolute;
inset: 0;
pointer-events: none;
}
.ovsb__root {
position: absolute;
top: 0;
bottom: 0;
right: var(--ovsb-right, 2px);
width: var(--ovsb-active-width, 10px);
display: flex;
justify-content: center;
align-items: stretch;
z-index: 20;
transition: opacity var(--ovsb-trans, 240ms ease), width var(--ovsb-trans, 240ms ease);
opacity: 0;
pointer-events: none; /* let scroll/wheel go to the host */
}
.ovsb--hidden { opacity: 0; pointer-events: none; }
.ovsb--mini { opacity: 1; }
.ovsb--active { opacity: 1; }
.ovsb__track {
position: absolute;
top: 0; bottom: 0;
right: 0; width: var(--ovsb-active-width, 10px);
background: var(--ovsb-track-bg, rgba(0,0,0,.18));
border-radius: 9999px;
opacity: var(--ovsb-track-op, 0);
transition: opacity var(--ovsb-trans, 240ms ease);
pointer-events: none;
}
.ovsb--active .ovsb__track { opacity: 0.35; }
.ovsb__thumb {
position: absolute;
top: var(--ovsb-thumb-top, 0px);
height: var(--ovsb-thumb-height, 24px);
width: var(--ovsb-mini-width, 6px);
right: 0;
border-radius: 9999px;
background: var(--ovsb-thumb-bg, rgba(255,255,255,.65));
box-shadow: 0 0 0 1px rgba(0,0,0,.12);
pointer-events: auto; /* only the thumb is interactive */
cursor: var(--ovsb-cursor, grab);
transition: background-color var(--ovsb-trans, 240ms ease), width var(--ovsb-trans, 240ms ease), opacity var(--ovsb-trans, 240ms ease);
}
.ovsb--active .ovsb__thumb {
width: var(--ovsb-active-width, 10px);
background: var(--ovsb-thumb-bg-active, rgba(255,255,255,.9));
cursor: var(--ovsb-cursor-active, grabbing);
}
@media (prefers-reduced-motion: reduce) {
.ovsb__root,
.ovsb__thumb,
.ovsb__track { transition: none !important; }
}

View File

@ -0,0 +1,29 @@
import { Component, Input, ChangeDetectionStrategy, ElementRef, Renderer2 } from '@angular/core';
export type OvsbState = 'hidden' | 'mini' | 'active';
@Component({
selector: 'app-overlay-scrollbar',
standalone: true,
templateUrl: './overlay-scrollbar.component.html',
styleUrl: './overlay-scrollbar.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'ovsb',
}
})
export class OverlayScrollbarComponent {
@Input() state: OvsbState = 'hidden';
@Input() ariaControls: string | null = null;
@Input() orientation: 'vertical' | 'horizontal' = 'vertical';
@Input() miniWidth = 6; // px
@Input() activeWidth = 10; // px
@Input() right = 2; // px
@Input() minHandle = 24; // px
constructor(public el: ElementRef<HTMLElement>, private r: Renderer2) {}
setCSSVar(name: string, value: string) {
this.r.setStyle(this.el.nativeElement, name, value);
}
}

View File

@ -0,0 +1,228 @@
import { Directive, ElementRef, Input, NgZone, OnDestroy, OnInit, Renderer2, inject, ViewContainerRef, ComponentRef } from '@angular/core';
import { OverlayScrollbarComponent, OvsbState } from './overlay-scrollbar.component';
@Directive({
selector: '[appScrollableOverlay]',
standalone: true,
})
export class ScrollableOverlayDirective implements OnInit, OnDestroy {
@Input() ovsbHideDelay = 1500;
@Input() ovsbMiniWidth = 6;
@Input() ovsbActiveWidth = 10;
@Input() ovsbMinHandle = 24;
@Input() ovsbRight = 2;
private host = inject<ElementRef<HTMLElement>>(ElementRef);
private zone = inject(NgZone);
private r = inject(Renderer2);
private vcr = inject(ViewContainerRef);
private compRef: ComponentRef<OverlayScrollbarComponent> | null = null;
private state: OvsbState = 'hidden';
private idleTimer: any = null;
private dragging = false;
private dragStartY = 0;
private dragStartTop = 0;
private cleanup: Array<() => void> = [];
private resizeObs?: ResizeObserver;
ngOnInit(): void {
// Ensure host is scrollable container
const el = this.host.nativeElement;
const style = getComputedStyle(el);
if (style.position === 'static') {
this.r.setStyle(el, 'position', 'relative');
}
this.r.addClass(el, 'ovsb-host');
this.zone.runOutsideAngular(() => {
// Create overlay component if not already present
this.compRef = this.vcr.createComponent(OverlayScrollbarComponent);
const overlayEl = this.compRef.location.nativeElement as HTMLElement;
this.r.appendChild(el, overlayEl);
// Apply initial vars
this.setCSSVar('--ovsb-mini-width', `${this.ovsbMiniWidth}px`);
this.setCSSVar('--ovsb-active-width', `${this.ovsbActiveWidth}px`);
this.setCSSVar('--ovsb-thumb-height', `${this.ovsbMinHandle}px`);
this.setCSSVar('--ovsb-right', `${this.ovsbRight}px`);
// Set ARIA attrs on thumb
const thumb = overlayEl.querySelector('.ovsb__thumb') as HTMLElement;
if (thumb) {
thumb.setAttribute('aria-valuemin', '0');
thumb.setAttribute('aria-orientation', 'vertical');
}
// Listeners
this.register(el, 'mouseenter', this.onMouseEnter, { passive: true });
this.register(el, 'mouseleave', this.onMouseLeave, { passive: true });
this.register(el, 'mousemove', this.onMouseMove, { passive: true });
this.register(el, 'wheel', this.onWheel, { passive: true });
this.register(el, 'scroll', this.onScroll, { passive: true });
this.register(el, 'touchstart', this.onTouchMove, { passive: true });
this.register(el, 'touchmove', this.onTouchMove, { passive: true });
this.register(el, 'touchend', this.onTouchEnd, { passive: true });
if (thumb) {
this.register(thumb, 'mousedown', this.onDragStart);
this.register(thumb, 'keydown', this.onKeyDown);
}
this.register(window, 'mousemove', this.onDragMove, { passive: true });
this.register(window, 'mouseup', this.onDragEnd, { passive: true });
// Resize observer to recompute
this.resizeObs = new ResizeObserver(() => this.updateThumb());
this.resizeObs.observe(el);
// Initial compute
const scrollable = this.updateThumb();
if (scrollable) {
this.toMini();
} else {
// If not scrollable, keep native behavior and hide overlay
this.setState('hidden');
this.r.removeClass(el, 'ovsb-host');
}
});
}
ngOnDestroy(): void {
this.clearIdle();
this.resizeObs?.disconnect();
for (const c of this.cleanup) try { c(); } catch {}
this.cleanup = [];
this.compRef?.destroy();
this.compRef = null;
}
// Event handlers (bound with .bind(this))
private onMouseEnter = () => { this.toMini(); };
private onMouseLeave = () => { this.scheduleIdle(); };
private onMouseMove = (e: MouseEvent) => { this.pointerActivity(e.clientX, e.clientY); };
private onWheel = () => { this.toMini(); };
private onScroll = () => { this.toMini(); this.updateThumb(); };
private onTouchMove = () => { this.toMini(); };
private onTouchEnd = () => { this.scheduleIdle(); };
private onDragStart = (e: MouseEvent) => {
e.preventDefault();
const overlayEl = this.compRef?.location.nativeElement as HTMLElement;
const thumb = overlayEl?.querySelector('.ovsb__thumb') as HTMLElement;
if (!thumb) return;
this.dragging = true;
this.setState('active');
this.dragStartY = e.clientY;
this.dragStartTop = parseFloat(getComputedStyle(thumb).top || '0');
this.setCSSVar('--ovsb-cursor', 'grabbing');
};
private onDragMove = (e: MouseEvent) => {
if (!this.dragging) return;
const el = this.host.nativeElement;
const overlayEl = this.compRef?.location.nativeElement as HTMLElement;
const thumb = overlayEl?.querySelector('.ovsb__thumb') as HTMLElement;
if (!thumb) return;
const delta = e.clientY - this.dragStartY;
const containerH = el.clientHeight;
const handleH = Math.max(this.ovsbMinHandle, (el.clientHeight / Math.max(el.scrollHeight, 1)) * el.clientHeight);
const maxThumbTop = Math.max(containerH - handleH, 0);
const newTop = Math.min(Math.max(this.dragStartTop + delta, 0), maxThumbTop);
// Map to scrollTop
const maxScroll = Math.max(el.scrollHeight - el.clientHeight, 1);
const ratio = newTop / Math.max(maxThumbTop, 1);
el.scrollTop = ratio * maxScroll;
this.updateThumb();
};
private onDragEnd = () => {
if (!this.dragging) return;
this.dragging = false;
this.toMini();
this.setCSSVar('--ovsb-cursor', 'grab');
};
private onKeyDown = (e: KeyboardEvent) => {
const el = this.host.nativeElement;
const step = Math.max(24, Math.floor(el.clientHeight * 0.1));
switch (e.key) {
case 'ArrowUp': el.scrollTop -= step; e.preventDefault(); break;
case 'ArrowDown': el.scrollTop += step; e.preventDefault(); break;
case 'PageUp': el.scrollTop -= el.clientHeight; e.preventDefault(); break;
case 'PageDown': el.scrollTop += el.clientHeight; e.preventDefault(); break;
case 'Home': el.scrollTop = 0; e.preventDefault(); break;
case 'End': el.scrollTop = el.scrollHeight; e.preventDefault(); break;
default: return;
}
this.toMini();
this.updateThumb();
};
// State helpers
private setState(next: OvsbState) {
if (this.state === next) return;
this.state = next;
if (this.compRef) {
this.compRef.instance.state = next;
this.compRef.changeDetectorRef.detectChanges();
}
}
private toMini() { this.clearIdle(); this.setState('mini'); this.scheduleIdle(); }
private scheduleIdle() { this.clearIdle(); this.idleTimer = setTimeout(() => this.setState('hidden'), this.ovsbHideDelay); }
private clearIdle() { if (this.idleTimer) { clearTimeout(this.idleTimer); this.idleTimer = null; } }
private pointerActivity(clientX: number, clientY: number) {
const el = this.host.nativeElement;
const rect = el.getBoundingClientRect();
const nearRight = clientX >= rect.right - (this.ovsbActiveWidth + 6) && clientX <= rect.right + 4 && clientY >= rect.top && clientY <= rect.bottom;
if (nearRight) this.setState('active'); else this.toMini();
}
private updateThumb(): boolean {
const el = this.host.nativeElement;
const overlayEl = this.compRef?.location.nativeElement as HTMLElement;
if (!overlayEl) return false;
const viewportH = el.clientHeight;
const scrollH = Math.max(el.scrollHeight, 1);
const containerH = viewportH;
const scrollable = scrollH > viewportH + 1; // allow small epsilon
if (!scrollable) {
// no scrolling needed
this.setCSSVar('--ovsb-thumb-height', `${this.ovsbMinHandle}px`);
this.setCSSVar('--ovsb-thumb-top', `0px`);
return false;
}
const handleH = Math.max(this.ovsbMinHandle, (viewportH / scrollH) * containerH);
const maxScroll = Math.max(scrollH - viewportH, 1);
const maxThumbTop = Math.max(containerH - handleH, 0);
const handleTop = Math.min(Math.max((el.scrollTop / maxScroll) * maxThumbTop, 0), maxThumbTop);
// CSS vars for component
this.setCSSVar('--ovsb-thumb-height', `${handleH}px`);
this.setCSSVar('--ovsb-thumb-top', `${handleTop}px`);
// ARIA attrs
const thumb = overlayEl.querySelector('.ovsb__thumb') as HTMLElement;
if (thumb) {
thumb.setAttribute('aria-valuemax', `${maxScroll}`);
thumb.setAttribute('aria-valuenow', `${Math.round(el.scrollTop)}`);
}
return true;
}
private setCSSVar(name: string, value: string) {
const overlayEl = this.compRef?.location.nativeElement as HTMLElement;
if (overlayEl) this.r.setStyle(overlayEl, name, value);
}
private register(target: HTMLElement | Window, type: string, handler: any, options?: AddEventListenerOptions) {
const bound = handler.bind(this);
target.addEventListener(type, bound, options);
this.cleanup.push(() => target.removeEventListener(type, bound, options));
}
}

View File

@ -0,0 +1,48 @@
import { Injectable, signal, effect } from '@angular/core';
type MobileTab = 'sidebar' | 'list' | 'page' | 'toc';
@Injectable({ providedIn: 'root' })
export class MobileNavService {
activeTab = signal<MobileTab>('list');
sidebarOpen = signal<boolean>(false);
tocOpen = signal<boolean>(false);
constructor() {
// Load from storage
try {
const t = localStorage.getItem('obsiviewer-mobile-tab');
if (t === 'sidebar' || t === 'list' || t === 'page' || t === 'toc') {
this.activeTab.set(t);
}
} catch {}
effect(() => {
try {
localStorage.setItem('obsiviewer-mobile-tab', this.activeTab());
} catch {}
});
}
setActiveTab(tab: MobileTab) {
this.activeTab.set(tab);
if (tab === 'sidebar') {
this.sidebarOpen.set(true);
this.tocOpen.set(false);
} else if (tab === 'toc') {
this.tocOpen.set(true);
this.sidebarOpen.set(false);
} else {
this.sidebarOpen.set(false);
this.tocOpen.set(false);
}
}
toggleSidebar() {
this.sidebarOpen.update(v => !v);
}
toggleToc() {
this.tocOpen.update(v => !v);
}
}

View File

@ -0,0 +1,17 @@
import { Injectable, signal, inject } from '@angular/core';
import { BreakpointObserver } from '@angular/cdk/layout';
@Injectable({ providedIn: 'root' })
export class ResponsiveService {
private breakpointObserver = inject(BreakpointObserver);
isMobile = signal<boolean>(false);
isTablet = signal<boolean>(false);
isDesktop = signal<boolean>(false);
constructor() {
this.breakpointObserver.observe('(max-width: 767px)').subscribe(r => this.isMobile.set(r.matches));
this.breakpointObserver.observe('(min-width: 768px) and (max-width: 1023px)').subscribe(r => this.isTablet.set(r.matches));
this.breakpointObserver.observe('(min-width: 1024px)').subscribe(r => this.isDesktop.set(r.matches));
}
}

View File

@ -0,0 +1,30 @@
import { Injectable, signal, effect } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class UiModeService {
isNimbusMode = signal<boolean>(this.loadUIMode());
constructor() {
effect(() => {
try {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('obsiviewer-ui-mode', this.isNimbusMode() ? 'nimbus' : 'legacy');
}
} catch {}
});
}
toggleUIMode(): void {
this.isNimbusMode.set(!this.isNimbusMode());
}
private loadUIMode(): boolean {
try {
if (typeof localStorage === 'undefined') return true;
const saved = localStorage.getItem('obsiviewer-ui-mode');
if (saved === 'nimbus') return true;
if (saved === 'legacy') return false;
} catch {}
return true;
}
}

View File

@ -13,7 +13,7 @@ import { VaultService } from '../../services/vault.service';
@let folder = node; @let folder = node;
<div> <div>
<div <div
(click)="toggleFolder(folder)" (click)="onFolderClick(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" 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"> <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">
@ -27,26 +27,30 @@ import { VaultService } from '../../services/vault.service';
<app-file-explorer <app-file-explorer
[nodes]="folder.children" [nodes]="folder.children"
[selectedNoteId]="selectedNoteId()" [selectedNoteId]="selectedNoteId()"
[foldersOnly]="foldersOnly()"
(folderSelected)="folderSelected.emit($event)"
(fileSelected)="onFileSelected($event)" (fileSelected)="onFileSelected($event)"
></app-file-explorer> ></app-file-explorer>
</div> </div>
} }
</div> </div>
} @else { } @else {
@let file = node; @if (!foldersOnly()) {
<div @let file = node;
(click)="onFileSelected(file.id)" <div
class="flex items-center cursor-pointer p-2 rounded my-0.5 text-sm ml-5" (click)="onFileSelected(file.id)"
[class.bg-obs-l-bg-main]="selectedNoteId() === file.id" class="flex items-center cursor-pointer p-2 rounded my-0.5 text-sm ml-5"
[class.dark:bg-obs-d-bg-main]="selectedNoteId() === file.id" [class.bg-obs-l-bg-main]="selectedNoteId() === file.id"
[class.hover:bg-obs-l-bg-main]="selectedNoteId() !== file.id" [class.dark:bg-obs-d-bg-main]="selectedNoteId() === file.id"
[class.dark:hover: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 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">
</svg> <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" />
<span class="truncate">{{ file.name }}</span> </svg>
</div> <span class="truncate">{{ file.name }}</span>
</div>
}
} }
</li> </li>
} }
@ -58,7 +62,9 @@ import { VaultService } from '../../services/vault.service';
export class FileExplorerComponent { export class FileExplorerComponent {
nodes = input.required<VaultNode[]>(); nodes = input.required<VaultNode[]>();
selectedNoteId = input<string | null>(null); selectedNoteId = input<string | null>(null);
foldersOnly = input<boolean>(false);
fileSelected = output<string>(); fileSelected = output<string>();
folderSelected = output<string>();
private vaultService = inject(VaultService); private vaultService = inject(VaultService);
@ -68,6 +74,13 @@ export class FileExplorerComponent {
} }
} }
onFolderClick(folder: VaultFolder) {
this.toggleFolder(folder);
if (folder?.path) {
this.folderSelected.emit(folder.path);
}
}
toggleFolder(folder: VaultFolder) { toggleFolder(folder: VaultFolder) {
this.vaultService.toggleFolder(folder.path); this.vaultService.toggleFolder(folder.path);
} }

View File

@ -35,24 +35,6 @@ interface TagGroup {
--tv-scroll-thumb: rgba(200, 200, 200, 0.2); --tv-scroll-thumb: rgba(200, 200, 200, 0.2);
} }
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: var(--tv-scroll-thumb) var(--tv-scroll-track);
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: var(--tv-scroll-track);
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: var(--tv-scroll-thumb);
border-radius: 3px;
}
.tag-item { .tag-item {
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
} }
@ -180,7 +162,7 @@ interface TagGroup {
</div> </div>
<!-- Tags list --> <!-- Tags list -->
<div class="flex-1 overflow-y-auto custom-scrollbar"> <div class="flex-1 overflow-y-auto" appScrollableOverlay>
@if (displayedGroups().length === 0) { @if (displayedGroups().length === 0) {
<div class="flex flex-col items-center justify-center h-full text-obs-l-text-muted dark:text-obs-d-text-muted p-8"> <div class="flex flex-col items-center justify-center h-full text-obs-l-text-muted dark:text-obs-d-text-muted p-8">
<svg class="h-12 w-12 mb-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="h-12 w-12 mb-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">

View File

@ -0,0 +1,10 @@
/**
* Configuration de l'environnement de production.
* Cette configuration est utilisée lors de la construction pour la production avec `ng build --configuration=production`.
*
* @property {boolean} production - Indique si l'application est en mode production (toujours true dans ce fichier).
*/
export const environment = {
production: true,
serviceURL: "/AuMenuManager",
};

View File

@ -0,0 +1,11 @@
/**
* Configuration de l'environnement de développement.
* Cette configuration est utilisée lors du développement local avec `ng serve`.
*
* @property {boolean} production - Indique si l'application est en mode production (false en développement).
*/
export const environment = {
production: false,
serviceURL: "http://localhost:8080/AuMenuManager",
// serviceURL: "https://public-tomcat.guru.lan/AuMenuManager",
};

View File

@ -1,4 +1,5 @@
@import './styles-test.css'; @import './styles-test.css';
@import './styles/_overlay-scrollbar.css';
/* Excalidraw CSS variables (thème sombre) */ /* Excalidraw CSS variables (thème sombre) */
/* .excalidraw { /* .excalidraw {
@ -256,6 +257,8 @@ excalidraw-editor .excalidraw .layer-ui__wrapper {
--btn-hover-background: color-mix(in srgb, var(--bg-muted) 42%, transparent); --btn-hover-background: color-mix(in srgb, var(--bg-muted) 42%, transparent);
--btn-focus-ring: color-mix(in srgb, var(--border) 45%, transparent); --btn-focus-ring: color-mix(in srgb, var(--border) 45%, transparent);
--btn-muted-text: var(--text-muted); --btn-muted-text: var(--text-muted);
--scrollbar-thumb-color: rgba(148, 163, 184, 0.45);
--scrollbar-thumb-color-active: rgba(148, 163, 184, 0.75);
} }
.dark { .dark {
@ -270,6 +273,8 @@ excalidraw-editor .excalidraw .layer-ui__wrapper {
--btn-hover-background: color-mix(in srgb, var(--bg-muted) 36%, transparent); --btn-hover-background: color-mix(in srgb, var(--bg-muted) 36%, transparent);
--btn-focus-ring: color-mix(in srgb, var(--border) 55%, transparent); --btn-focus-ring: color-mix(in srgb, var(--border) 55%, transparent);
--btn-muted-text: var(--text-muted); --btn-muted-text: var(--text-muted);
--scrollbar-thumb-color: rgba(148, 163, 184, 0.35);
--scrollbar-thumb-color-active: rgba(226, 232, 240, 0.72);
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
@ -281,6 +286,57 @@ excalidraw-editor .excalidraw .layer-ui__wrapper {
} }
@layer components { @layer components {
/* Adaptive scrollbar states */
.adaptive-scrollbar {
position: relative;
scrollbar-width: thin;
scrollbar-gutter: stable both-edges;
--scrollbar-thumb-transition: 240ms ease;
}
.adaptive-scrollbar[data-scroll-state="idle"] {
scrollbar-width: none;
scrollbar-color: transparent transparent;
}
.adaptive-scrollbar[data-scroll-state="mini"] {
scrollbar-color: color-mix(in srgb, var(--scrollbar-thumb-color) 80%, transparent) transparent;
}
.adaptive-scrollbar[data-scroll-state="active"] {
scrollbar-color: color-mix(in srgb, var(--scrollbar-thumb-color-active) 100%, transparent) transparent;
}
.adaptive-scrollbar::-webkit-scrollbar {
width: 10px;
height: 10px;
background-color: transparent;
}
.adaptive-scrollbar::-webkit-scrollbar-thumb {
border-radius: 9999px;
background-color: transparent;
box-shadow: inset 0 0 0 1px transparent;
transition: background-color var(--scrollbar-thumb-transition), box-shadow var(--scrollbar-thumb-transition), opacity var(--scrollbar-thumb-transition);
opacity: 0;
}
.adaptive-scrollbar[data-scroll-state="mini"]::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-thumb-color);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
opacity: 0.8;
}
.adaptive-scrollbar[data-scroll-state="active"]::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-thumb-color-active);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18);
opacity: 1;
}
.adaptive-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.calendar-compact { .calendar-compact {
/* @apply flex min-h-[180px] max-h-[200px] flex-col; */ /* @apply flex min-h-[180px] max-h-[200px] flex-col; */
display: flex; display: flex;

View File

@ -0,0 +1,209 @@
/* ===============================
Overlay Scrollbar Cross-browser (Edge/Firefox/Chrome/Safari)
Fichier : _overlay-scrollbar.css
=============================== */
/* ---------- Thèmes & variables ---------- */
:root {
/* Couleurs (clair) */
--ovsb-track-bg: rgba(15, 23, 42, 0.12);
--ovsb-thumb-bg: rgba(100, 116, 139, 0.55);
--ovsb-thumb-bg-active: rgba(59, 130, 246, 0.75);
/* Géométrie & transitions */
--ovsb-trans: 240ms ease;
--ovsb-right: 2px; /* Décalage à droite de loverlay */
--ovsb-thumb-height: 24px; /* Hauteur mini du thumb */
/* Épaisseurs / états
Astuce : on anime lépaisseur via scaleX pour éviter les changements de hit-area.
Le track garde une largeur de hit-test stable. */
--ovsb-active-width: 10px; /* largeur visuelle en état "active" */
--ovsb-mini-width: 2px; /* largeur visuelle en état "mini" */
/* Ratio (mini vs active) → sert à scaleX. Si vous modifiez les largeurs, adaptez ce ratio. */
--ovsb-mini-scale: 0.2; /* 2 / 10 = 0.2 */
}
.dark,
[data-theme="dark"] {
--ovsb-track-bg: rgba(148, 163, 184, 0.28);
--ovsb-thumb-bg: rgba(226, 232, 240, 0.72);
--ovsb-thumb-bg-active: rgba(241, 245, 249, 0.92);
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.ovsb,
.ovsb__root,
.ovsb__track,
.ovsb__thumb {
transition: none !important;
}
}
/* ========================================
1) Conteneur scrollable (hôte de loverlay)
======================================== */
/* IMPORTANT : appliquer .ovsb-host sur lélément QUI SCROLLE réellement */
.ovsb-host {
position: relative; /* nécessaire pour positionner loverlay en absolute */
overflow: auto; /* cest bien LUI qui scrolle */
/* Masquer la scrollbar native (Firefox + Edge/Chrome/Safari) */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* Edge/IE legacy */
}
.ovsb-host::-webkit-scrollbar {
width: 0 !important; /* Chrome/Edge/Safari */
height: 0 !important;
background: transparent !important;
}
.ovsb-host::-webkit-scrollbar-thumb,
.ovsb-host::-webkit-scrollbar-track,
.ovsb-host::-webkit-scrollbar-corner {
background: transparent !important;
border: none !important;
}
/* Cas où le DOCUMENT scrolle (évitez si possible). Décommentez si nécessaire. */
/*
html, body {
scrollbar-width: none;
-ms-overflow-style: none;
}
html::-webkit-scrollbar,
body::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
}
*/
/* Si vous décidez de garder la native (non recommandé ici), stabilisez le gutter : */
/*
@supports (scrollbar-gutter: stable) {
.ovsb-host-native {
scrollbar-gutter: stable both-edges;
}
}
*/
/* ========================================
2) Overlay (track + thumb)
======================================== */
/* Racine overlay : ne capture pas les events hors track/thumb */
.ovsb {
position: absolute;
top: 0;
right: var(--ovsb-right);
bottom: 0;
width: 12px; /* largeur de HIT-TEST stable (garde la zone de survol constante) */
pointer-events: none; /* la racine ne capte pas, on laisse le contenu défiler naturellement */
z-index: 10;
contain: layout paint; /* isolation pour perf */
}
/* Nœud interne (utile si vous voulez des wrappers) */
.ovsb__root {
position: absolute;
inset: 0;
}
/* Piste (track) — zone cliquable/drag */
.ovsb__track {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 12px; /* hit-area stable */
border-radius: 9999px;
background: var(--ovsb-track-bg);
opacity: 0; /* géré par états */
pointer-events: auto; /* le track doit capter pour hover/drag */
transition: opacity var(--ovsb-trans);
will-change: opacity;
}
/* Thumb (manette) — épaisseur animée via scaleX (pas de shift) */
.ovsb__thumb {
position: absolute;
top: 0; /* la position Y est appliquée via transform translateY */
right: 1px; /* léger décalage pour équilibre visuel */
width: var(--ovsb-active-width);
height: var(--ovsb-thumb-height);
border-radius: 9999px;
background: var(--ovsb-thumb-bg);
/* Position & épaisseur animées :
translateY: mis à jour dynamiquement (via style inline ou CSS var)
scaleX: mini/active
*/
transform: translateY(var(--ovsb-thumb-y, 0px)) scaleX(var(--ovsb-mini-scale));
transform-origin: right center;
transition: transform var(--ovsb-trans), background-color var(--ovsb-trans), opacity var(--ovsb-trans);
opacity: 0; /* masqué par défaut */
pointer-events: auto; /* doit capter pour le drag */
will-change: transform, opacity, background-color;
cursor: grab;
}
.ovsb__thumb:active {
cursor: grabbing;
}
/* ========================================
3) États (Hidden / Mini / Active)
======================================== */
/* HIDDEN : invisible, aucune interactivité */
.ovsb--hidden .ovsb__track,
.ovsb--hidden .ovsb__thumb {
opacity: 0;
pointer-events: none; /* rien ne capte, laisse le contenu réagir librement */
}
/* MINI : visible, thumb mince (scaleX mini), track discret */
.ovsb--mini .ovsb__track {
opacity: 1;
}
.ovsb--mini .ovsb__thumb {
opacity: 1;
background: var(--ovsb-thumb-bg);
transform: translateY(var(--ovsb-thumb-y, 0px)) scaleX(var(--ovsb-mini-scale));
}
/* ACTIVE : au survol de la zone scrollbar → thumb plus épais + couleur active */
.ovsb--active .ovsb__track {
opacity: 1;
}
.ovsb--active .ovsb__thumb {
opacity: 1;
background: var(--ovsb-thumb-bg-active);
transform: translateY(var(--ovsb-thumb-y, 0px)) scaleX(1);
}
/* Hover direct sur la zone track → active visuelle (utile en desktop) */
.ovsb__track:hover ~ .ovsb__thumb,
.ovsb__thumb:hover {
background: var(--ovsb-thumb-bg-active);
}
/* ========================================
4) Divers (compat & accessibilité visuelle)
======================================== */
/* Améliore la réactivité GPU (Edge/Chrome) */
.ovsb__track,
.ovsb__thumb {
backface-visibility: hidden;
}
/* Quand overlay affiché, éviter le scroll chaining vers parent si souhaité */
.ovsb-host {
overscroll-behavior: contain;
}
/* Cas tactile : permettre drag propre sans scroll involontaire horizontal */
.ovsb__thumb,
.ovsb__track {
touch-action: none;
}

View File

@ -9,6 +9,14 @@ module.exports = {
darkMode: ['class', '[data-theme="dark"]'], darkMode: ['class', '[data-theme="dark"]'],
theme: { theme: {
extend: { extend: {
screens: {
xs: '320px',
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
'2xl': '1536px',
},
colors: { colors: {
'bg-main': 'var(--bg-main)', 'bg-main': 'var(--bg-main)',
'bg-muted': 'var(--bg-muted)', 'bg-muted': 'var(--bg-muted)',
@ -25,7 +33,12 @@ module.exports = {
warning: 'var(--warning)', warning: 'var(--warning)',
danger: 'var(--danger)', danger: 'var(--danger)',
info: 'var(--info)', info: 'var(--info)',
ring: 'var(--ring)' ring: 'var(--ring)',
nimbus: {
50: '#f0f9ff',
500: '#0ea5e9',
900: '#0c4a6e'
}
}, },
ringColor: { ringColor: {
DEFAULT: 'var(--ring)' DEFAULT: 'var(--ring)'