feat: enhance navigation and URL state management

- Added clickable "Quick Links" header to navigate to all pages view
- Implemented URL normalization to handle multiple section params (tag/folder/quick) with priority rules
- Added suppressNextNoteSelection flag to prevent auto-selection for certain quick link actions
- Updated URL state service to use setQuickWithMarkdown for consistent navigation state
- Improved path normalization to handle backslashes and leading slashes consistently
- Added distinct
This commit is contained in:
Bruno Charest 2025-10-30 21:34:45 -04:00
parent 1545dbb20a
commit cbdb000d4b
5 changed files with 177 additions and 39 deletions

View File

@ -62,7 +62,7 @@ import { FilterService } from '../../services/filter.service';
<span class="flex items-center gap-2">
<span class="text-xs text-muted">{{ open.quick ? '▾' : '▸' }}</span>
<span></span>
<span>Quick Links</span>
<a href="/" (click)="$event.stopPropagation(); onQuickLinksHeaderClick($event)" class="hover:underline">Quick Links</a>
</span>
</button>
<div *ngIf="open.quick" class="pt-1">
@ -241,11 +241,16 @@ export class NimbusSidebarComponent implements OnChanges {
onHomeClick(event: MouseEvent): void {
event.preventDefault();
this.toggleSection('quick');
this.quickLinkSelected.emit('all');
queueMicrotask(async () => {
await this.urlState.filterByKind('markdown');
});
this.open = { quick: true, folders: false, tags: false, trash: false, tests: false };
this.sidebar.open('quick');
void this.urlState.setQuickWithMarkdown('all');
}
onQuickLinksHeaderClick(event: MouseEvent): void {
event.preventDefault();
this.open = { quick: true, folders: false, tags: false, trash: false, tests: false };
this.sidebar.open('quick');
void this.urlState.setQuickWithMarkdown('all');
}
onMarkdownPlaygroundClick(): void {

View File

@ -395,6 +395,7 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
private flyoutCloseTimer: any = null;
tagFilter: string | null = null;
quickLinkFilter: 'favoris' | 'publish' | 'draft' | 'template' | 'task' | 'private' | 'archive' | null = null;
private suppressNextNoteSelection = false;
// --- URL State <-> Layout sync ---
private mapUrlQuickToInternal(q: string | null): AppShellNimbusLayoutComponent['quickLinkFilter'] {
@ -511,14 +512,24 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
this.quickLinkFilter = internal;
this.folderFilter = null;
this.tagFilter = null;
if (!hasNote) this.autoSelectFirstNote();
if (!this.responsive.isDesktop()) this.mobileNav.setActiveTab('list');
if (internal === 'favoris') {
this.suppressNextNoteSelection = true;
}
if (!hasNote && !this.suppressNextNoteSelection) {
this.autoSelectFirstNote();
}
if (!this.responsive.isDesktop()) {
this.mobileNav.setActiveTab('list');
}
} else if (!hasNote && !this.suppressNextNoteSelection) {
this.autoSelectFirstNote();
}
// Auto-open quick flyout when quick filter is active
if (this.hoveredFlyout !== 'quick') {
console.log('🎨 Layout - opening quick flyout for quick filter');
this.openFlyout('quick');
}
this.suppressNextNoteSelection = false;
} else {
// No filters -> show all
if (this.folderFilter || this.tagFilter || this.quickLinkFilter) {
@ -527,6 +538,7 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
this.quickLinkFilter = null;
if (!hasNote) this.autoSelectFirstNote();
}
this.suppressNextNoteSelection = false;
// Close any open flyout when no filters
if (this.hoveredFlyout) {
console.log('🎨 Layout - closing flyout (no active filters)');
@ -722,6 +734,9 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
}
onQuickLink(_id: string) {
const suppressAutoSelect = _id === 'all' || _id === 'favorites';
this.suppressNextNoteSelection = suppressAutoSelect;
if (_id === 'all') {
// Show all pages: clear filters and focus list
this.folderFilter = null;
@ -735,7 +750,7 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
this.mobileNav.setActiveTab('list');
}
this.scheduleCloseFlyout(150);
this.urlState.showAllAndReset();
this.urlState.setQuickWithMarkdown('all');
} else if (_id === 'publish') {
// Filter by publish: true
this.folderFilter = null;
@ -749,7 +764,9 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
}
this.scheduleCloseFlyout(150);
const label = this.mapInternalQuickToUrl('publish');
if (label) this.urlState.filterByQuickLink(label);
if (label) {
this.urlState.setQuickWithMarkdown(label);
}
} else if (_id === 'favorites') {
// Filter by favoris: true
this.folderFilter = null;
@ -763,7 +780,9 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
}
this.scheduleCloseFlyout(150);
const label = this.mapInternalQuickToUrl('favoris');
if (label) this.urlState.filterByQuickLink(label);
if (label) {
this.urlState.setQuickWithMarkdown(label);
}
} else if (_id === 'templates') {
// Filter by template: true
this.folderFilter = null;
@ -777,7 +796,9 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
}
this.scheduleCloseFlyout(150);
const label = this.mapInternalQuickToUrl('template');
if (label) this.urlState.filterByQuickLink(label);
if (label) {
this.urlState.setQuickWithMarkdown(label);
}
} else if (_id === 'tasks') {
// Filter by task: true
this.folderFilter = null;
@ -791,7 +812,9 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
}
this.scheduleCloseFlyout(150);
const label = this.mapInternalQuickToUrl('task');
if (label) this.urlState.filterByQuickLink(label);
if (label) {
this.urlState.setQuickWithMarkdown(label);
}
} else if (_id === 'drafts') {
// Filter by draft: true
this.folderFilter = null;
@ -805,7 +828,9 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
}
this.scheduleCloseFlyout(150);
const label = this.mapInternalQuickToUrl('draft');
if (label) this.urlState.filterByQuickLink(label);
if (label) {
this.urlState.setQuickWithMarkdown(label);
}
} else if (_id === 'private') {
// Filter by private: true
this.folderFilter = null;
@ -819,7 +844,9 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
}
this.scheduleCloseFlyout(150);
const label = this.mapInternalQuickToUrl('private');
if (label) this.urlState.filterByQuickLink(label);
if (label) {
this.urlState.setQuickWithMarkdown(label);
}
} else if (_id === 'archive') {
// Filter by archive: true
this.folderFilter = null;
@ -833,10 +860,15 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
}
this.scheduleCloseFlyout(150);
const label = this.mapInternalQuickToUrl('archive');
if (label) this.urlState.filterByQuickLink(label);
if (label) {
this.urlState.setQuickWithMarkdown(label);
}
}
// Auto-select first note after filter changes
this.autoSelectFirstNote();
if (!this.suppressNextNoteSelection) {
this.autoSelectFirstNote();
}
this.suppressNextNoteSelection = false;
}
onTagSelected(tagName: string) {

View File

@ -1,7 +1,7 @@
import { Injectable, inject, signal, effect, computed, OnDestroy } from '@angular/core';
import { Router, NavigationEnd } from '@angular/router';
import { VaultService } from '../../services/vault.service';
import { filter, takeUntil, map } from 'rxjs/operators';
import { filter, takeUntil, map, distinctUntilChanged } from 'rxjs/operators';
import { startWith } from 'rxjs';
import { Subject } from 'rxjs';
import type { Note } from '../../types';
@ -168,6 +168,22 @@ export class UrlStateService implements OnDestroy {
console.log('🌐 UrlStateService - new state:', newState);
const previousState = this.currentStateSignal();
// Normalize when multiple section params (tag/folder/quick) coexist in the raw URL
// Keep only the active one determined by priority, preserve note/search/kind
const rawHasMultiple = (Number(!!params['tag']) + Number(!!params['folder']) + Number(!!params['quick'])) > 1;
if (rawHasMultiple) {
setTimeout(() => {
const active = this.getActiveSection(newState);
if (active) {
this.updateUrl({
tag: active === 'tag' ? newState.tag! : null,
folder: active === 'folder' ? newState.folder! : null,
quick: active === 'quick' ? newState.quick! : null,
});
}
}, 0);
}
const changed = this.detectChanges(previousState, newState);
console.log('🌐 UrlStateService - changed keys:', changed);
if (changed.length > 0) {
@ -208,6 +224,7 @@ export class UrlStateService implements OnDestroy {
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
this.stateChangeSubject.complete();
}
// ========================================
@ -320,15 +337,16 @@ export class UrlStateService implements OnDestroy {
return;
}
const note = this.vaultService.allNotes().find(n => n.filePath === trimmed);
const normalized = trimmed.replace(/\\/g, '/').replace(/^\/+/, '');
const note = this.vaultService.allNotes().find(n => n.filePath === normalized);
if (!note && !options?.force) {
console.warn(`Note not found: ${trimmed}`);
console.warn(`Note not found: ${normalized}`);
return;
}
// Mettre à jour l'URL (même si la note n'est pas encore connue localement lorsqu'on force)
await this.updateUrl({ note: trimmed });
await this.updateUrl({ note: normalized });
}
/**
@ -351,7 +369,7 @@ export class UrlStateService implements OnDestroy {
*/
async filterByFolder(folder: string): Promise<void> {
// Vérifier que le dossier existe
const fileTree = this.vaultService.fileTree();
const fileTree = this.vaultService.fileTree() ?? [];
const folderExists = this.folderExistsInTree(fileTree, folder);
if (!folderExists) {
@ -382,17 +400,25 @@ export class UrlStateService implements OnDestroy {
* Filtrer par quick link
*/
async filterByQuickLink(quickLink: string): Promise<void> {
const validQuickLinks = ['all', 'All Pages', 'Favoris', 'Publié', 'Modèles', 'Tâches', 'Brouillons', 'Privé', 'Archive', 'Corbeille', 'favorites', 'publish', 'drafts', 'templates', 'tasks', 'private', 'archive'];
const raw = (quickLink ?? '').trim().toLowerCase();
const mapQuick = new Map<string, string>([
['all', 'all'], ['all pages', 'all'],
['favoris', 'Favoris'], ['favorites', 'favorites'],
['publié', 'Publié'], ['publie', 'Publié'], ['publish', 'publish'],
['modèles', 'Modèles'], ['modeles', 'Modèles'], ['templates', 'templates'],
['tâches', 'Tâches'], ['taches', 'Tâches'], ['tasks', 'tasks'],
['brouillons', 'Brouillons'], ['drafts', 'drafts'],
['privé', 'Privé'], ['prive', 'Privé'], ['private', 'private'],
['archive', 'Archive'], ['corbeille', 'Corbeille']
]);
if (!validQuickLinks.includes(quickLink)) {
const canonical = mapQuick.get(raw);
if (!canonical) {
console.warn(`Invalid quick link: ${quickLink}`);
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
const newQuickValue = canonical === 'all' ? null : canonical;
await this.updateUrl({ quick: newQuickValue, note: null, tag: null, folder: null, search: null });
}
@ -439,22 +465,20 @@ export class UrlStateService implements OnDestroy {
const normalized = (notePath ?? '').trim()
.replace(/\\/g, '/')
.replace(/^\/+/, '');
if (!normalized) {
console.warn('setNote() called with empty path');
return;
}
const queryParams: any = { note: normalized };
const partial: Partial<UrlState> = { note: normalized, tag: null, quick: null };
if (folderPath) {
queryParams.folder = folderPath.replace(/\\/g, '/').replace(/^\/+/, '');
partial.folder = folderPath.replace(/\\/g, '/').replace(/^\/+/, '');
} else {
partial.folder = null;
}
await this.router.navigate([], {
queryParams,
queryParamsHandling: 'merge',
preserveFragment: true
});
await this.updateUrl(partial);
}
/**
@ -483,11 +507,27 @@ export class UrlStateService implements OnDestroy {
async resetState(): Promise<void> {
await this.router.navigate([], {
queryParams: {},
queryParamsHandling: 'merge',
queryParamsHandling: '',
preserveFragment: true
});
}
/**
* Appliquer en une seule navigation: quick (ou aucun) + kind=markdown,
* en réinitialisant les autres filtres (note, tag, folder, search).
*/
async setQuickWithMarkdown(quickLabel: string | null): Promise<void> {
const partial: Partial<UrlState> = {
note: null,
tag: null,
folder: null,
search: null,
quick: quickLabel ?? null,
kind: 'markdown',
};
await this.updateUrl(partial);
}
/**
* Générer une URL partageble
*/
@ -503,7 +543,9 @@ export class UrlStateService implements OnDestroy {
if (stateToShare.search) params.set('search', stateToShare.search);
if (stateToShare.kind) params.set('kind', stateToShare.kind);
const baseUrl = window.location.origin + window.location.pathname;
const baseUrl = (typeof window !== 'undefined')
? window.location.origin + window.location.pathname
: this.router.serializeUrl(this.router.createUrlTree([]));
return `${baseUrl}?${params.toString()}`;
}
@ -543,7 +585,8 @@ export class UrlStateService implements OnDestroy {
*/
onStatePropertyChange(property: keyof UrlState) {
return this.stateChangeSubject.asObservable().pipe(
filter(event => event.changed.includes(property))
filter(event => event.changed.includes(property)),
distinctUntilChanged((a, b) => a.current[property] === b.current[property])
);
}

View File

@ -0,0 +1,58 @@
---
titre: Nouveau-markdown
auteur: Bruno Charest
creation_date: 2025-10-19T21:42:53-04:00
modification_date: 2025-10-30T21:24:35-04:00
catégorie: ""
tags: []
aliases: []
status: en-cours
publish: true
favoris: false
template: true
task: true
archive: true
draft: true
private: true
toto: tata
color: "#EF4444"
---
Allo ceci est un tests
toto
# Test 1 Markdown
## Titres
# Niveau 1
#tag1 #tag2 #test #test2
# Nouveau-markdown
## sous-titre
- [ ] allo
- [ ] toto
- [ ] tata
## sous-titre 2
#tag1 #tag2 #tag3 #tag4
## sous-titre 3
## sous-titre 4
## sous-titre 5
test
## sous-titre 6
test
## sous-titre 7
test
## sous-titre 8