feat: enhance navigation and filtering behavior

- Added clickable app title links in header and sidebar that reset view to all markdown files
- Fixed quicklink filtering to use correct frontmatter boolean keys (favoris, publish, draft, etc.)
- Updated sidebar behavior to auto-open Quick Links section when viewing markdown files
- Added filter clearing when switching between views to prevent conflicting filters
- Modified URL state handling to preserve kind parameter when navigating
- Enforced markdown-only
This commit is contained in:
Bruno Charest 2025-10-30 16:15:45 -04:00
parent 58b22a47c9
commit 1545dbb20a
7 changed files with 109 additions and 27 deletions

View File

@ -0,0 +1,29 @@
import { test, expect } from '@playwright/test';
/**
* Quick Links Search/Filters Notes-list sync
* - One click on a Quick Link shows the filter pill above the search immediately
* - Notes-list updates accordingly
*/
test.describe('Quick Links state sync', () => {
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:4200');
await page.waitForLoadState('networkidle');
});
test('one-click Favoris shows pill above search', async ({ page }) => {
// Open Quick Links if inside an accordion in desktop left sidebar
await page.getByText('Quick Links').first().click({ trial: true }).catch(() => {});
// Click on Favoris in Quick Links
await page.getByText('Favoris', { exact: true }).first().click();
// Expect a pill with label "Favoris" to appear above the search input in the list panel
const pill = page.locator('app-filter-badge .label', { hasText: 'Favoris' });
await expect(pill).toBeVisible({ timeout: 3000 });
// URL should contain quick=Favoris
await expect.poll(async () => new URL(page.url()).searchParams.get('quick')).toBe('Favoris');
});
});

View File

@ -318,7 +318,9 @@
<div class="min-w-0 flex flex-col gap-1"> <div class="min-w-0 flex flex-col gap-1">
<div class="flex items-center gap-2 min-w-0"> <div class="flex items-center gap-2 min-w-0">
<span class="inline-flex items-center rounded-lg border border-border bg-bg-muted/70 px-2.5 py-1 text-xs font-semibold uppercase tracking-wide text-text-muted">{{ vaultName() }}</span> <span class="inline-flex items-center rounded-lg border border-border bg-bg-muted/70 px-2.5 py-1 text-xs font-semibold uppercase tracking-wide text-text-muted">{{ vaultName() }}</span>
<h1 class="text-lg font-semibold leading-tight text-text-main">ObsiWatcher</h1> <h1 class="text-lg font-semibold leading-tight text-text-main">
<a href="/" (click)="onAppTitleClick($event)" class="hover:underline">ObsiWatcher</a>
</h1>
</div> </div>
@if (selectedNoteBreadcrumb().length > 0) { @if (selectedNoteBreadcrumb().length > 0) {
<p class="truncate text-xs text-text-muted">{{ selectedNoteBreadcrumb().join(' / ') }}</p> <p class="truncate text-xs text-text-muted">{{ selectedNoteBreadcrumb().join(' / ') }}</p>

View File

@ -884,6 +884,16 @@ export class AppComponent implements OnInit, OnDestroy {
} }
} }
onAppTitleClick(event: MouseEvent): void {
event.preventDefault();
this.isSidebarOpen.set(true);
this.activeView.set('files');
queueMicrotask(async () => {
await this.urlState.showAllAndReset();
await this.urlState.filterByKind('markdown');
});
}
onCalendarSearchErrorChange(message: string | null): void { onCalendarSearchErrorChange(message: string | null): void {
this.calendarSearchError.set(message); this.calendarSearchError.set(message);
if (message) { if (message) {

View File

@ -663,13 +663,14 @@ export class NotesListComponent {
private mapInternalQuickToFrontmatter(id: 'favoris' | 'publish' | 'draft' | 'template' | 'task' | 'private' | 'archive' | null): string | null { private mapInternalQuickToFrontmatter(id: 'favoris' | 'publish' | 'draft' | 'template' | 'task' | 'private' | 'archive' | null): string | null {
switch (id || '') { switch (id || '') {
case 'favoris': return 'Favoris'; // Match actual frontmatter boolean keys used in the vault service and writers
case 'publish': return 'Publié'; case 'favoris': return 'favoris';
case 'draft': return 'Brouillons'; case 'publish': return 'publish';
case 'template': return 'Modèles'; case 'draft': return 'draft';
case 'task': return 'Tâches'; case 'template': return 'template';
case 'private': return 'Privé'; case 'task': return 'task';
case 'archive': return 'Archive'; case 'private': return 'private';
case 'archive': return 'archive';
default: return null; default: return null;
} }
} }
@ -720,9 +721,13 @@ export class NotesListComponent {
if (quickLink) { if (quickLink) {
const fmKey = this.mapInternalQuickToFrontmatter(quickLink); const fmKey = this.mapInternalQuickToFrontmatter(quickLink);
// Enforce Markdown-only when a Quick Link is active and match frontmatter boolean flag
list = list.filter(n => { list = list.filter(n => {
const frontmatter = n.frontmatter || {} as any; const frontmatter = n.frontmatter || ({} as any);
return fmKey ? frontmatter[fmKey] === true : false; return (
this.matchesKind(n, 'markdown') &&
(fmKey ? frontmatter[fmKey] === true : false)
);
}); });
} }

View File

@ -20,10 +20,10 @@ import { FilterService } from '../../services/filter.service';
<div class="h-full flex flex-col overflow-hidden select-none"> <div class="h-full flex flex-col overflow-hidden select-none">
<!-- Header --> <!-- Header -->
<div class="h-12 flex items-center justify-between px-3 border-b border-border dark:border-gray-800"> <div class="h-12 flex items-center justify-between px-3 border-b border-border dark:border-gray-800">
<div class="flex items-center gap-2 min-w-0"> <a href="/" (click)="onHomeClick($event)" class="flex items-center gap-2 min-w-0 text-inherit no-underline">
<img src="assets/favicon.svg" alt="ObsiViewer" class="h-6 w-6 flex-shrink-0" /> <img src="assets/favicon.svg" alt="ObsiViewer" class="h-6 w-6 flex-shrink-0" />
<div class="text-sm font-semibold truncate">{{ vaultName }} - ObsiViewer</div> <span class="text-sm font-semibold truncate">{{ vaultName }} - ObsiViewer</span>
</div> </a>
<button (click)="toggleSidebarRequest.emit()" class="p-2 rounded hover:bg-surface1 dark:hover:bg-card" title="Hide Sidebar"></button> <button (click)="toggleSidebarRequest.emit()" class="p-2 rounded hover:bg-surface1 dark:hover:bg-card" title="Hide Sidebar"></button>
</div> </div>
@ -217,7 +217,7 @@ export class NimbusSidebarComponent implements OnChanges {
@Output() aboutSelected = new EventEmitter<void>(); @Output() aboutSelected = new EventEmitter<void>();
env = environment; env = environment;
open = { quick: true, folders: true, tags: false, trash: false, tests: false }; open = { quick: true, folders: false, tags: false, trash: false, tests: false };
private vault = inject(VaultService); private vault = inject(VaultService);
urlState = inject(UrlStateService); urlState = inject(UrlStateService);
private sidebar = inject(SidebarStateService); private sidebar = inject(SidebarStateService);
@ -239,6 +239,15 @@ export class NimbusSidebarComponent implements OnChanges {
onQuickLink(id: string) { this.quickLinkSelected.emit(id); } onQuickLink(id: string) { this.quickLinkSelected.emit(id); }
onHomeClick(event: MouseEvent): void {
event.preventDefault();
this.toggleSection('quick');
this.quickLinkSelected.emit('all');
queueMicrotask(async () => {
await this.urlState.filterByKind('markdown');
});
}
onMarkdownPlaygroundClick(): void { onMarkdownPlaygroundClick(): void {
this.markdownPlaygroundSelected.emit(); this.markdownPlaygroundSelected.emit();
} }

View File

@ -21,6 +21,7 @@ import { TestExcalidrawPageComponent } from '../../features/tests/test-excalidra
import { ParametersPage } from '../../features/parameters/parameters.page'; import { ParametersPage } from '../../features/parameters/parameters.page';
import { AboutPanelComponent } from '../../features/about/about-panel.component'; import { AboutPanelComponent } from '../../features/about/about-panel.component';
import { UrlStateService } from '../../services/url-state.service'; import { UrlStateService } from '../../services/url-state.service';
import { FilterService } from '../../services/filter.service';
import { NoteInfoModalComponent } from '../../features/note-info/note-info-modal.component'; import { NoteInfoModalComponent } from '../../features/note-info/note-info-modal.component';
import { NoteInfoModalService } from '../../services/note-info-modal.service'; import { NoteInfoModalService } from '../../services/note-info-modal.service';
import { InPageSearchService } from '../../shared/search/in-page-search.service'; import { InPageSearchService } from '../../shared/search/in-page-search.service';
@ -67,7 +68,11 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
[selectedNoteId]="selectedNoteId" [selectedNoteId]="selectedNoteId"
[tags]="tags" [tags]="tags"
[quickLinkFilter]="quickLinkFilter" [quickLinkFilter]="quickLinkFilter"
[forceOpenSection]="tagFilter ? 'tags' : (folderFilter ? 'folders' : (quickLinkFilter ? 'quick' : null))" [forceOpenSection]="
(!tagFilter && !folderFilter && !quickLinkFilter && urlState.activeKind() === 'markdown')
? 'quick'
: (tagFilter ? 'tags' : (folderFilter ? 'folders' : (quickLinkFilter ? 'quick' : null)))
"
(toggleSidebarRequest)="toggleSidebarRequest.emit()" (toggleSidebarRequest)="toggleSidebarRequest.emit()"
(folderSelected)="onFolderSelected($event)" (folderSelected)="onFolderSelected($event)"
(fileSelected)="noteSelected.emit($event)" (fileSelected)="noteSelected.emit($event)"
@ -341,6 +346,7 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
responsive = inject(ResponsiveService); responsive = inject(ResponsiveService);
mobileNav = inject(MobileNavService); mobileNav = inject(MobileNavService);
urlState = inject(UrlStateService); urlState = inject(UrlStateService);
filters = inject(FilterService);
noteInfo = inject(NoteInfoModalService); noteInfo = inject(NoteInfoModalService);
inPageSearch = inject(InPageSearchService); inPageSearch = inject(InPageSearchService);
@ -722,17 +728,22 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
this.tagFilter = null; this.tagFilter = null;
this.quickLinkFilter = null; this.quickLinkFilter = null;
this.listQuery = ''; this.listQuery = '';
// Clear local filters (kinds, cumulative tags) to avoid conflicting filters
try { this.filters.clearKinds(); } catch {}
try { this.filters.clearTags(); } catch {}
if (!this.responsive.isDesktop()) { if (!this.responsive.isDesktop()) {
this.mobileNav.setActiveTab('list'); this.mobileNav.setActiveTab('list');
} }
this.scheduleCloseFlyout(150); this.scheduleCloseFlyout(150);
this.urlState.resetState(); this.urlState.showAllAndReset();
} else if (_id === 'publish') { } else if (_id === 'publish') {
// Filter by publish: true // Filter by publish: true
this.folderFilter = null; this.folderFilter = null;
this.tagFilter = null; this.tagFilter = null;
this.quickLinkFilter = 'publish'; this.quickLinkFilter = 'publish';
this.listQuery = ''; this.listQuery = '';
try { this.filters.clearKinds(); } catch {}
try { this.filters.clearTags(); } catch {}
if (!this.responsive.isDesktop()) { if (!this.responsive.isDesktop()) {
this.mobileNav.setActiveTab('list'); this.mobileNav.setActiveTab('list');
} }
@ -745,6 +756,8 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
this.tagFilter = null; this.tagFilter = null;
this.quickLinkFilter = 'favoris'; this.quickLinkFilter = 'favoris';
this.listQuery = ''; this.listQuery = '';
try { this.filters.clearKinds(); } catch {}
try { this.filters.clearTags(); } catch {}
if (!this.responsive.isDesktop()) { if (!this.responsive.isDesktop()) {
this.mobileNav.setActiveTab('list'); this.mobileNav.setActiveTab('list');
} }
@ -757,6 +770,8 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
this.tagFilter = null; this.tagFilter = null;
this.quickLinkFilter = 'template'; this.quickLinkFilter = 'template';
this.listQuery = ''; this.listQuery = '';
try { this.filters.clearKinds(); } catch {}
try { this.filters.clearTags(); } catch {}
if (!this.responsive.isDesktop()) { if (!this.responsive.isDesktop()) {
this.mobileNav.setActiveTab('list'); this.mobileNav.setActiveTab('list');
} }
@ -769,6 +784,8 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
this.tagFilter = null; this.tagFilter = null;
this.quickLinkFilter = 'task'; this.quickLinkFilter = 'task';
this.listQuery = ''; this.listQuery = '';
try { this.filters.clearKinds(); } catch {}
try { this.filters.clearTags(); } catch {}
if (!this.responsive.isDesktop()) { if (!this.responsive.isDesktop()) {
this.mobileNav.setActiveTab('list'); this.mobileNav.setActiveTab('list');
} }
@ -781,6 +798,8 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
this.tagFilter = null; this.tagFilter = null;
this.quickLinkFilter = 'draft'; this.quickLinkFilter = 'draft';
this.listQuery = ''; this.listQuery = '';
try { this.filters.clearKinds(); } catch {}
try { this.filters.clearTags(); } catch {}
if (!this.responsive.isDesktop()) { if (!this.responsive.isDesktop()) {
this.mobileNav.setActiveTab('list'); this.mobileNav.setActiveTab('list');
} }
@ -793,6 +812,8 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
this.tagFilter = null; this.tagFilter = null;
this.quickLinkFilter = 'private'; this.quickLinkFilter = 'private';
this.listQuery = ''; this.listQuery = '';
try { this.filters.clearKinds(); } catch {}
try { this.filters.clearTags(); } catch {}
if (!this.responsive.isDesktop()) { if (!this.responsive.isDesktop()) {
this.mobileNav.setActiveTab('list'); this.mobileNav.setActiveTab('list');
} }
@ -805,6 +826,8 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
this.tagFilter = null; this.tagFilter = null;
this.quickLinkFilter = 'archive'; this.quickLinkFilter = 'archive';
this.listQuery = ''; this.listQuery = '';
try { this.filters.clearKinds(); } catch {}
try { this.filters.clearTags(); } catch {}
if (!this.responsive.isDesktop()) { if (!this.responsive.isDesktop()) {
this.mobileNav.setActiveTab('list'); this.mobileNav.setActiveTab('list');
} }

View File

@ -189,7 +189,8 @@ export class UrlStateService implements OnDestroy {
folder: nextSection === 'folder' ? newState.folder! : null, folder: nextSection === 'folder' ? newState.folder! : null,
quick: nextSection === 'quick' ? newState.quick! : null, quick: nextSection === 'quick' ? newState.quick! : null,
search: null, search: null,
kind: 'all', // Preserve provided kind if any; fallback to 'all'
kind: newState.kind ?? 'all',
note: null, note: null,
}; };
this.updateUrl(partial); this.updateUrl(partial);
@ -381,15 +382,18 @@ export class UrlStateService implements OnDestroy {
* Filtrer par quick link * Filtrer par quick link
*/ */
async filterByQuickLink(quickLink: string): Promise<void> { async filterByQuickLink(quickLink: string): Promise<void> {
const validQuickLinks = ['Favoris', 'Publié', 'Modèles', 'Tâches', 'Brouillons', 'Privé', 'Archive', 'Corbeille']; const validQuickLinks = ['all', 'All Pages', 'Favoris', 'Publié', 'Modèles', 'Tâches', 'Brouillons', 'Privé', 'Archive', 'Corbeille', 'favorites', 'publish', 'drafts', 'templates', 'tasks', 'private', 'archive'];
if (!validQuickLinks.includes(quickLink)) { if (!validQuickLinks.includes(quickLink)) {
console.warn(`Invalid quick link: ${quickLink}`); console.warn(`Invalid quick link: ${quickLink}`);
return; return;
} }
// Si 'All Pages' est sélectionné, on veut supprimer le filtre 'quick'.
const newQuickValue = (quickLink === 'all' || quickLink === 'All Pages') ? null : quickLink;
// Mettre à jour l'URL // Mettre à jour l'URL
await this.updateUrl({ quick: quickLink, note: null, tag: null, folder: null, search: null }); await this.updateUrl({ quick: newQuickValue, note: null, tag: null, folder: null, search: null });
} }
/** /**
@ -460,15 +464,15 @@ export class UrlStateService implements OnDestroy {
const currentState = this.currentStateSignal(); const currentState = this.currentStateSignal();
const newState = { ...currentState, ...partialState }; const newState = { ...currentState, ...partialState };
// Nettoyer les propriétés undefined // Nettoyer les propriétés undefined ou null
const cleanState = Object.fromEntries( const cleanState = Object.fromEntries(
Object.entries(newState).filter(([_, v]) => v !== undefined) Object.entries(newState).filter(([_, v]) => v !== undefined && v !== null)
); );
// Naviguer avec les nouveaux queryParams // Naviguer avec les nouveaux queryParams, en remplaçant les anciens.
await this.router.navigate([], { await this.router.navigate([], {
queryParams: cleanState, queryParams: cleanState,
queryParamsHandling: 'merge', queryParamsHandling: '', // Remplacer au lieu de fusionner
preserveFragment: true preserveFragment: true
}); });
} }