feat: enhance notes list with filtering, sorting and view modes
- Added comprehensive filtering system with support for file types, tags, and quick links - Implemented sort options (title, created, updated) with dropdown menu - Added three view modes (compact, comfortable, detailed) for different density layouts - Added note color indicators and hover actions (edit, delete) to note cards - Integrated file type detection with appropriate icons for different content types - Added filter badges to show
This commit is contained in:
		
							parent
							
								
									57da40f25f
								
							
						
					
					
						commit
						545c07a4b3
					
				
							
								
								
									
										260
									
								
								docs/ARCHITECTURE/PERF_ROADMAP_TODOLIST.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										260
									
								
								docs/ARCHITECTURE/PERF_ROADMAP_TODOLIST.md
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,260 @@
 | 
				
			|||||||
 | 
					# ObsiViewer Nimbus — Performance Roadmap & Todo List (Angular 20 + Tailwind 3.4)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Cette roadmap est une liste d’actions priorisées pour optimiser les performances côté client de l’interface Nimbus, en respectant strictement:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Angular 20 (signals, @for/@if, @defer, standalone, zoneless)
 | 
				
			||||||
 | 
					- TailwindCSS 3.4 (purge actuelle conservée)
 | 
				
			||||||
 | 
					- UI/UX Nimbus existante (aucun changement visuel ou de comportement attendu)
 | 
				
			||||||
 | 
					- Modes Desktop et Mobile
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Chaque tâche contient: Objectif mesurable, Critères d’acceptation, Emplacements de code, Risques/Dépendances et un Prompt prêt à l’emploi pour exécuter la tâche à 100% (desktop + mobile).
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## ✅ Phase 0 — Quick Wins (ROI élevé, faible risque)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 0.1 Retirer l’import global Excalidraw du bundle initial
 | 
				
			||||||
 | 
					- Objectif: réduire la taille du bundle initial et le TTI en excluant `@excalidraw/excalidraw` du chunk principal.
 | 
				
			||||||
 | 
					- Critères d’acceptation:
 | 
				
			||||||
 | 
					  - L’import global n’apparaît plus dans `index.tsx`.
 | 
				
			||||||
 | 
					  - Le Drawings Editor reste fonctionnel via import dynamique (`web-components/excalidraw/define`).
 | 
				
			||||||
 | 
					  - Build prod montre une baisse du poids du bundle initial (source-map-explorer).
 | 
				
			||||||
 | 
					- Emplacements: `index.tsx`, `src/app/features/drawings/drawings-editor.component.ts`, `web-components/excalidraw/define.ts`.
 | 
				
			||||||
 | 
					- Risques/Dépendances: s’assurer que les usages Excalidraw restent lazy et conditionnels.
 | 
				
			||||||
 | 
					- Prompt:
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					Tu es un pair-programmer Angular 20. Objectif: retirer tout import global d'Excalidraw du bundle initial sans casser le Drawings Editor. Étapes:
 | 
				
			||||||
 | 
					1) Ouvre `index.tsx` et supprime toute ligne `import '@excalidraw/excalidraw'`.
 | 
				
			||||||
 | 
					2) Vérifie que `src/app/features/drawings/drawings-editor.component.ts` importe `../../../../web-components/excalidraw/define` via `await import(...)`.
 | 
				
			||||||
 | 
					3) Lance un build prod avec stats et analyse les bundles (source-map-explorer) pour confirmer la réduction du main chunk.
 | 
				
			||||||
 | 
					4) Teste Desktop et Mobile: ouverture d’un fichier .excalidraw.md dans l’éditeur (réactivité, chargement lazy OK).
 | 
				
			||||||
 | 
					5) Ne change pas l’UI/UX. Respecte Angular 20 et Tailwind 3.4.
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 0.2 Virtualiser la liste centrale Nimbus avec `PaginatedNotesList`
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					- Objectif: maintenir ≥55 FPS et mémoire <200MB avec 5k+ notes.
 | 
				
			||||||
 | 
					- Critères d’acceptation:
 | 
				
			||||||
 | 
					  - Le centre affiche `PaginatedNotesListComponent` (CDK virtual scroll + pagination).
 | 
				
			||||||
 | 
					  - Scroll fluide Desktop/Mobile, FPS ≥55 sur 5k items.
 | 
				
			||||||
 | 
					  - Émissions/inputs existants (openNote, queryChange, selectedId, quickLink) préservés.
 | 
				
			||||||
 | 
					- Emplacements: `src/app/layout/app-shell-nimbus/app-shell-nimbus.component.ts` (zones de liste Desktop/Tablet/Mobile).
 | 
				
			||||||
 | 
					- Risques/Dépendances: compatibilité filtres/search/URL state; conserver événements.
 | 
				
			||||||
 | 
					- Prompt:
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					Tu es un expert Angular 20/CDK Virtual Scroll. Remplace la liste centrale dans `AppShellNimbus` par `PaginatedNotesListComponent` sans changer l’UX.
 | 
				
			||||||
 | 
					1) Identifie les trois zones de rendu de la liste (desktop/tablet/mobile) dans `app-shell-nimbus.component.ts`.
 | 
				
			||||||
 | 
					2) Remplace le composant de liste actuel par `<app-paginated-notes-list>` en câblant:
 | 
				
			||||||
 | 
					   - Inputs: folderFilter, tagFilter, quickLinkFilter, query, selectedId
 | 
				
			||||||
 | 
					   - Outputs: openNote, queryChange, clearQuickLinkFilter
 | 
				
			||||||
 | 
					3) Vérifie Desktop/Mobile: scroll fluide, sélection, recherche, quick links.
 | 
				
			||||||
 | 
					4) Mesure FPS (DevTools) sur 5k notes; objectif ≥55 FPS.
 | 
				
			||||||
 | 
					5) Pas de modification visuelle. Respecte Angular 20/Tailwind 3.4.
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 0.3 Déférer les viewers lourds (PDF/Video/Excalidraw/Code) via `@defer`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Objectif: réduire LCP et TTI en chargeant à la demande les viewers lourds.
 | 
				
			||||||
 | 
					- Critères d’acceptation:
 | 
				
			||||||
 | 
					  - `@defer` entourant le SmartFileViewer (non-markdown) avec placeholder.
 | 
				
			||||||
 | 
					  - Le chunk viewer se charge uniquement quand visible (on viewport/interactions).
 | 
				
			||||||
 | 
					  - Pas de régression Desktop/Mobile.
 | 
				
			||||||
 | 
					- Emplacements: `src/components/tags-view/note-viewer/note-viewer.component.ts` (ou template), `src/components/smart-file-viewer/`.
 | 
				
			||||||
 | 
					- Risques/Dépendances: placeholders légers; événements image/video conservés.
 | 
				
			||||||
 | 
					- Prompt:
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					Tu es un spécialiste Angular 20. Déferre l’affichage des viewers lourds.
 | 
				
			||||||
 | 
					1) Dans le template NoteViewer (ou SmartFileViewer), entoure le rendu non-markdown avec `@defer (on viewport)` et un placeholder léger.
 | 
				
			||||||
 | 
					2) Garde le rendu markdown immédiat.
 | 
				
			||||||
 | 
					3) Vérifie Desktop/Mobile: ouverture d’un PDF/Excalidraw/Video/Code charge un chunk async seulement à l’affichage.
 | 
				
			||||||
 | 
					4) Mesure LCP/TTI avant/après (Lighthouse Mobile). Objectif: LCP ≤ 2.5s, TTI ≤ 3.0s.
 | 
				
			||||||
 | 
					5) Aucun changement d’UI. Respecte Angular 20/Tailwind 3.4.
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 0.4 Adopter `NgOptimizedImage` pour les images
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Objectif: améliorer LCP et éliminer CLS en fournissant dimensions/sizes.
 | 
				
			||||||
 | 
					- Critères d’acceptation:
 | 
				
			||||||
 | 
					  - `NgOptimizedImage` importé et `ngSrc` utilisé dans `image-viewer`.
 | 
				
			||||||
 | 
					  - Dimensions (width/height) ou stratégie calculée, `sizes` renseigné.
 | 
				
			||||||
 | 
					  - LCP image ↓ et CLS ≈ 0.
 | 
				
			||||||
 | 
					- Emplacements: `src/app/features/note-view/components/image-viewer/image-viewer.component.ts` (+ contenu markdown rendu si applicable).
 | 
				
			||||||
 | 
					- Risques/Dépendances: fournir dimensions raisonnables; ne pas dégrader mise en page.
 | 
				
			||||||
 | 
					- Prompt:
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					Tu es un dev Angular 20. Optimise les images avec NgOptimizedImage.
 | 
				
			||||||
 | 
					1) Dans `image-viewer.component.ts`, ajoute `NgOptimizedImage` aux imports standalone.
 | 
				
			||||||
 | 
					2) Remplace `src` par `ngSrc` et fournis `width`, `height`, `sizes`.
 | 
				
			||||||
 | 
					3) Vérifie Desktop/Mobile: absence de sauts de layout (CLS), affichage net, LCP image améliorée.
 | 
				
			||||||
 | 
					4) Garde les événements/émissions inchangés. Pas de changement visuel.
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 0.5 Convertir les `*ngFor` restants en `@for` avec `track`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Objectif: réduire le coût de diffing et stabiliser l’identité des items.
 | 
				
			||||||
 | 
					- Critères d’acceptation:
 | 
				
			||||||
 | 
					  - Listes dynamiques dans Nimbus utilisent `@for (...; track ...)`.
 | 
				
			||||||
 | 
					  - Aucune régression d’interaction (sélection tags/dossiers/etc.).
 | 
				
			||||||
 | 
					- Emplacements: `app-shell-nimbus.component.ts` (ex. flyout tags/dossiers) et autres listes restantes.
 | 
				
			||||||
 | 
					- Risques/Dépendances: choisir une clé stable (`id`, `name`).
 | 
				
			||||||
 | 
					- Prompt:
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					Tu es un expert Angular 20. Modernise les boucles.
 | 
				
			||||||
 | 
					1) Recherc he `*ngFor` dans les templates Nimbus et remplace-les par `@for`.
 | 
				
			||||||
 | 
					2) Ajoute un `track` stable (id, name) pour chaque liste.
 | 
				
			||||||
 | 
					3) Teste Desktop/Mobile: navigation, clics tags/dossiers, pas de re-rendu excessif.
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Phase 1 — Correctifs cœur
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 1.1 Throttling/Debouncing des entrées + écouteurs passifs
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Objectif: TBT ≤ 150ms, INP ≤ 200ms durant saisie/recherche/scroll.
 | 
				
			||||||
 | 
					- Critères d’acceptation:
 | 
				
			||||||
 | 
					  - Flux de recherche: debounce/throttle ~16–100ms.
 | 
				
			||||||
 | 
					  - Écouteurs scroll/wheel/resize en `{ passive: true }` si custom.
 | 
				
			||||||
 | 
					  - Profiler: long tasks ↓ et input latency ↓ sur Desktop/Mobile.
 | 
				
			||||||
 | 
					- Emplacements: composants de recherche/liste; directives éventuelles d’événements.
 | 
				
			||||||
 | 
					- Prompt:
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					Tu es un dev perf Angular. Lisse les entrées.
 | 
				
			||||||
 | 
					1) Ajoute debounce/throttle (RxJS) aux flux de recherche et scroll.
 | 
				
			||||||
 | 
					2) Convertis tout `addEventListener` custom en `{ passive: true }` si pertinent.
 | 
				
			||||||
 | 
					3) Mesure INP/TBT (Lighthouse Mobile). Objectifs: INP ≤ 200ms, TBT ≤ 150ms.
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 1.2 Déférer panneaux lourds (paramètres/tests/about)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Objectif: réduire bundle initial et coût de rendu.
 | 
				
			||||||
 | 
					- Critères d’acceptation:
 | 
				
			||||||
 | 
					  - Panneaux secondaires rendus via `@defer (on interaction)` + placeholder.
 | 
				
			||||||
 | 
					  - Aucun changement d’UX; charge uniquement à l’ouverture.
 | 
				
			||||||
 | 
					- Emplacements: `app-shell-nimbus.component.ts` (panneaux/overlays non essentiels).
 | 
				
			||||||
 | 
					- Prompt:
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					Tu es un expert Angular 20. Déferre les panneaux non essentiels.
 | 
				
			||||||
 | 
					1) Ajoute `@defer (on interaction)` autour des panneaux paramètres/tests/about.
 | 
				
			||||||
 | 
					2) Placeholder léger et accessibilité préservée.
 | 
				
			||||||
 | 
					3) Vérifie Desktop/Mobile: ouverture fluide, chunks chargés à la demande.
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 1.3 Vérifier import conditionnel des libs lourdes
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Objectif: `@excalidraw/excalidraw`, PDF et autres libs uniquement chargés si viewer requis.
 | 
				
			||||||
 | 
					- Critères d’acceptation: aucun import global résiduel; lazy import confirmé (network waterfall).
 | 
				
			||||||
 | 
					- Emplacements: `smart-file-viewer`, `drawings-editor`, pdf viewer.
 | 
				
			||||||
 | 
					- Prompt:
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					Tu es un dev Angular. Vérifie et force les imports conditionnels.
 | 
				
			||||||
 | 
					1) Inspecte smart-file-viewer/drawings-editor/pdf-viewer: pas d’import global.
 | 
				
			||||||
 | 
					2) Les imports doivent être dynamiques/conditionnels à l’usage.
 | 
				
			||||||
 | 
					3) Confirme au profiler: chunks chargés seulement si nécessaires.
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Phase 2 — Refactors profonds
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 2.1 Slices de store par signals (Sélection/Filtre/Recherche)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Objectif: diminuer les re-rendus et effets transverses.
 | 
				
			||||||
 | 
					- Critères d’acceptation:
 | 
				
			||||||
 | 
					  - Séparation claire des signals; reactivité localisée; pas de boucle.
 | 
				
			||||||
 | 
					  - UX identique; URL state toujours synchronisé.
 | 
				
			||||||
 | 
					- Emplacements: `AppComponent`, `AppShellNimbusLayoutComponent`, services d’état/URL.
 | 
				
			||||||
 | 
					- Prompt:
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					Tu es un architecte Angular 20. Isoles les états en slices par signals.
 | 
				
			||||||
 | 
					1) Identifie les states très utilisés (sélection, filtres, recherche).
 | 
				
			||||||
 | 
					2) Crée des slices/cohorts de signals avec computed/effects.
 | 
				
			||||||
 | 
					3) Assure la synchro URL <-> UI intacte. Tests Desktop/Mobile.
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 2.2 content-visibility/contain pour grands conteneurs
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Objectif: réduire le coût de layout/paint hors écran.
 | 
				
			||||||
 | 
					- Critères d’acceptation: scrolling et navigation plus fluides; aucune régression visuelle.
 | 
				
			||||||
 | 
					- Emplacements: grands conteneurs de liste/notes (CSS global ou scss locaux).
 | 
				
			||||||
 | 
					- Prompt:
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					Tu es un expert CSS perf. Ajoute `content-visibility: auto` et `contain-intrinsic-size` sur les grands conteneurs offscreen.
 | 
				
			||||||
 | 
					1) Cible les conteneurs principaux liste/viewer.
 | 
				
			||||||
 | 
					2) Vérifie Desktop/Mobile: aucun artefact; paint/layout réduits (Performance panel).
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 2.3 Pipeline images responsive (thumb→medium→full)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Objectif: améliorer le temps de rendu perçu des assets visuels.
 | 
				
			||||||
 | 
					- Critères d’acceptation: affichage progressif sans saut, qualité finale correcte.
 | 
				
			||||||
 | 
					- Emplacements: chargement d’images (viewer et markdown rendu).
 | 
				
			||||||
 | 
					- Prompt:
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					Tu es un dev front. Mets en place un pipeline responsive images.
 | 
				
			||||||
 | 
					1) Servez d’abord une miniature, puis une image medium, puis la full.
 | 
				
			||||||
 | 
					2) Adapte `sizes/srcset` (NgOptimizedImage) pour Desktop/Mobile.
 | 
				
			||||||
 | 
					3) Mesure LCP perçu et absence de CLS.
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Phase 3 — Plateforme / Build
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 3.1 Budgets Angular (initial, anyComponentStyle, css)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Objectif: prévenir les régressions de poids.
 | 
				
			||||||
 | 
					- Critères d’acceptation: budgets ajoutés à `angular.json`; build échoue si dépassement.
 | 
				
			||||||
 | 
					- Emplacements: `angular.json`.
 | 
				
			||||||
 | 
					- Prompt:
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					Tu es un mainteneur Angular. Ajoute des budgets stricts.
 | 
				
			||||||
 | 
					1) Ajoute budgets pour initial (warn 1400kb / error 1800kb), anyComponentStyle, css.
 | 
				
			||||||
 | 
					2) Lance un build prod pour valider.
 | 
				
			||||||
 | 
					3) Documente dans README/CI les seuils.
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### 3.2 Scripts d’analyse + (optionnel) Lighthouse CI gate
 | 
				
			||||||
 | 
					- [] done
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Objectif: visibilité continue et garde-fous automatiques.
 | 
				
			||||||
 | 
					- Critères d’acceptation: scripts npm `build:stats` et `analyze:bundle`; pipeline peut exécuter Lighthouse avec seuils mobiles.
 | 
				
			||||||
 | 
					- Emplacements: `package.json`, CI.
 | 
				
			||||||
 | 
					- Prompt:
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					Tu es un dev outillage. Ajoute scripts d’analyse et un job Lighthouse CI (optionnel).
 | 
				
			||||||
 | 
					1) `build:stats` (ng build --stats-json) et `analyze:bundle` (source-map-explorer) dans package.json.
 | 
				
			||||||
 | 
					2) Pipeline: exécute Lighthouse Mobile/Desktop et échoue si LCP/TTI/CLS/TBT hors seuils.
 | 
				
			||||||
 | 
					3) Ne modifie pas le code applicatif.
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Mesure & Validation
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Baseline puis post-implémentation: Lighthouse Mobile (4x CPU, 1.5 Mbps) et Desktop.
 | 
				
			||||||
 | 
					- Objectifs cibles:
 | 
				
			||||||
 | 
					  - LCP ≤ 2.5s (Mobile), TTI ≤ 3.0s, CLS ≤ 0.02, TBT ≤ 150ms
 | 
				
			||||||
 | 
					  - Liste 5k items: ≥55 FPS, mémoire <200MB
 | 
				
			||||||
 | 
					  - Bundle initial: -40% vs baseline si possible
 | 
				
			||||||
 | 
					- Scripts utiles:
 | 
				
			||||||
 | 
					  - `npm run build:stats`
 | 
				
			||||||
 | 
					  - `npx source-map-explorer "dist/**/*.js"`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Checklist globale (progression)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- [x] 0.1 Retirer import global Excalidraw
 | 
				
			||||||
 | 
					- [ ] 0.2 Virtualiser liste centrale Nimbus (PaginatedNotesList)
 | 
				
			||||||
 | 
					- [ ] 0.3 Déférer viewers lourds (@defer)
 | 
				
			||||||
 | 
					- [ ] 0.4 NgOptimizedImage pour images
 | 
				
			||||||
 | 
					- [ ] 0.5 Convertir *ngFor → @for avec track
 | 
				
			||||||
 | 
					- [ ] 1.1 Throttle/Debounce + passive listeners
 | 
				
			||||||
 | 
					- [ ] 1.2 Déférer panneaux non essentiels
 | 
				
			||||||
 | 
					- [ ] 1.3 Imports conditionnels libs lourdes
 | 
				
			||||||
 | 
					- [ ] 2.1 Slices signals (Sélection/Filtre/Recherche)
 | 
				
			||||||
 | 
					- [ ] 2.2 content-visibility/contain pour conteneurs
 | 
				
			||||||
 | 
					- [ ] 2.3 Pipeline images responsive
 | 
				
			||||||
 | 
					- [ ] 3.1 Budgets Angular
 | 
				
			||||||
 | 
					- [ ] 3.2 Scripts analyse + Lighthouse CI (opt.)
 | 
				
			||||||
@ -8,7 +8,6 @@ import { AppComponent } from './src/app.component';
 | 
				
			|||||||
import { provideViewers } from './src/app/services/file-viewer-registry.service';
 | 
					import { provideViewers } from './src/app/services/file-viewer-registry.service';
 | 
				
			||||||
import { initializeRouterLogging } from './src/core/logging/log.router-listener';
 | 
					import { initializeRouterLogging } from './src/core/logging/log.router-listener';
 | 
				
			||||||
import { initializeVisibilityLogging } from './src/core/logging/log.visibility-listener';
 | 
					import { initializeVisibilityLogging } from './src/core/logging/log.visibility-listener';
 | 
				
			||||||
import '@excalidraw/excalidraw';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
registerLocaleData(localeFr);
 | 
					registerLocaleData(localeFr);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -2,7 +2,16 @@ import { Component, EventEmitter, Output, input, signal, computed, effect, injec
 | 
				
			|||||||
import { CommonModule } from '@angular/common';
 | 
					import { CommonModule } from '@angular/common';
 | 
				
			||||||
import { ScrollingModule, CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
 | 
					import { ScrollingModule, CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
 | 
				
			||||||
import { PaginationService, NoteMetadata } from '../../services/pagination.service';
 | 
					import { PaginationService, NoteMetadata } from '../../services/pagination.service';
 | 
				
			||||||
 | 
					import { VaultService } from '../../../services/vault.service';
 | 
				
			||||||
 | 
					import { FileTypeDetectorService } from '../../../services/file-type-detector.service';
 | 
				
			||||||
import { TagFilterStore } from '../../core/stores/tag-filter.store';
 | 
					import { TagFilterStore } from '../../core/stores/tag-filter.store';
 | 
				
			||||||
 | 
					import { NotesListStateService, SortBy, ViewMode } from '../../services/notes-list-state.service';
 | 
				
			||||||
 | 
					import { FilterService } from '../../services/filter.service';
 | 
				
			||||||
 | 
					import { NoteContextMenuService } from '../../services/note-context-menu.service';
 | 
				
			||||||
 | 
					import { EditorStateService } from '../../../services/editor-state.service';
 | 
				
			||||||
 | 
					import { NoteContextMenuComponent } from '../../../components/note-context-menu/note-context-menu.component';
 | 
				
			||||||
 | 
					import { WarningPanelComponent } from '../../components/warning-panel/warning-panel.component';
 | 
				
			||||||
 | 
					import { FilterBadgeComponent } from '../../components/filter-badge/filter-badge.component';
 | 
				
			||||||
import { ScrollableOverlayDirective } from '../../shared/overlay-scrollbar/scrollable-overlay.directive';
 | 
					import { ScrollableOverlayDirective } from '../../shared/overlay-scrollbar/scrollable-overlay.directive';
 | 
				
			||||||
import { Subject } from 'rxjs';
 | 
					import { Subject } from 'rxjs';
 | 
				
			||||||
import { takeUntil } from 'rxjs/operators';
 | 
					import { takeUntil } from 'rxjs/operators';
 | 
				
			||||||
@ -10,11 +19,16 @@ import { takeUntil } from 'rxjs/operators';
 | 
				
			|||||||
@Component({
 | 
					@Component({
 | 
				
			||||||
  selector: 'app-paginated-notes-list',
 | 
					  selector: 'app-paginated-notes-list',
 | 
				
			||||||
  standalone: true,
 | 
					  standalone: true,
 | 
				
			||||||
  imports: [CommonModule, ScrollingModule, ScrollableOverlayDirective],
 | 
					  imports: [CommonModule, ScrollingModule, ScrollableOverlayDirective, NoteContextMenuComponent, WarningPanelComponent, FilterBadgeComponent],
 | 
				
			||||||
  template: `
 | 
					  template: `
 | 
				
			||||||
    <div class="h-full flex flex-col">
 | 
					    <div class="h-full flex flex-col">
 | 
				
			||||||
      <!-- Search and filters header -->
 | 
					      <!-- Search and filters header -->
 | 
				
			||||||
      <div class="p-2 border-b border-border dark:border-gray-800 space-y-2">
 | 
					      <div class="p-2 border-b border-border dark:border-gray-800 space-y-2">
 | 
				
			||||||
 | 
					        <!-- Unified badges row -->
 | 
				
			||||||
 | 
					        <div class="flex flex-wrap items-center gap-1.5 min-h-[1.75rem]">
 | 
				
			||||||
 | 
					          <app-filter-badge *ngFor="let b of filter.badges()"
 | 
				
			||||||
 | 
					            [label]="b.label" [icon]="b.icon" (remove)="filter.removeBadge(b)"></app-filter-badge>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
        <div *ngIf="activeTag() as t" class="flex items-center gap-2 text-xs">
 | 
					        <div *ngIf="activeTag() as t" class="flex items-center gap-2 text-xs">
 | 
				
			||||||
          <span class="inline-flex items-center gap-1 rounded-full bg-surface1 dark:bg-card text-main dark:text-main px-2 py-1">
 | 
					          <span class="inline-flex items-center gap-1 rounded-full bg-surface1 dark:bg-card text-main dark:text-main px-2 py-1">
 | 
				
			||||||
            <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 7h.01"/><path d="M3 7h5a2 2 0 0 1 1.414.586l7 7a2 2 0 0 1 0 2.828l-3.172 3.172a2 2 0 0 1-2.828 0l-7-7A2 2 0 0 1 3 12V7Z"/></svg>
 | 
					            <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 7h.01"/><path d="M3 7h5a2 2 0 0 1 1.414.586l7 7a2 2 0 0 1 0 2.828l-3.172 3.172a2 2 0 0 1-2.828 0l-7-7A2 2 0 0 1 3 12V7Z"/></svg>
 | 
				
			||||||
@ -35,8 +49,37 @@ import { takeUntil } from 'rxjs/operators';
 | 
				
			|||||||
        <input type="text"
 | 
					        <input type="text"
 | 
				
			||||||
               [value]="query()"
 | 
					               [value]="query()"
 | 
				
			||||||
               (input)="onQuery($any($event.target).value)"
 | 
					               (input)="onQuery($any($event.target).value)"
 | 
				
			||||||
 | 
					               (keydown.enter)="onSearchEnter()"
 | 
				
			||||||
               placeholder="Rechercher..."
 | 
					               placeholder="Rechercher..."
 | 
				
			||||||
               class="w-full rounded border border-border dark:border-border bg-card dark:bg-main px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
 | 
					               class="w-full rounded border border-border dark:border-border bg-card dark:bg-main px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <!-- Action Buttons (Sort + View Mode) -->
 | 
				
			||||||
 | 
					        <div class="action-buttons flex justify-between items-center">
 | 
				
			||||||
 | 
					          <div class="flex items-center gap-2 relative">
 | 
				
			||||||
 | 
					            <button type="button" (click)="toggleSortMenu()" class="inline-flex items-center justify-center w-8 h-8 rounded hover:bg-surface2/50 dark:hover:bg-surface2/50 transition-colors" title="Trier par">
 | 
				
			||||||
 | 
					              <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M7 12h10"/><path d="M10 18h4"/></svg>
 | 
				
			||||||
 | 
					            </button>
 | 
				
			||||||
 | 
					            <button type="button" (click)="toggleViewModeMenu()" class="inline-flex items-center justify-center w-8 h-8 rounded hover:bg-surface2/50 dark:hover:bg-surface2/50 transition-colors" title="Mode d'affichage">
 | 
				
			||||||
 | 
					              <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
 | 
				
			||||||
 | 
					            </button>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            <!-- Sort Dropdown -->
 | 
				
			||||||
 | 
					            <div *ngIf="sortMenuOpen()" class="absolute top-full left-0 mt-1 bg-card dark:bg-main border border-border dark:border-gray-700 rounded shadow-lg z-10 min-w-max">
 | 
				
			||||||
 | 
					              <button type="button" *ngFor="let s of sortOptions" (click)="setSortBy(s)" [class.bg-surface1]="state.sortBy() === s" class="w-full text-left px-3 py-2 text-xs hover:bg-surface1 dark:hover:bg-surface2 transition-colors">{{ getSortLabel(s) }}</button>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            <!-- View Mode Dropdown -->
 | 
				
			||||||
 | 
					            <div *ngIf="viewModeMenuOpen()" class="absolute top-full left-10 mt-1 bg-card dark:bg-main border border-border dark:border-gray-700 rounded shadow-lg z-10 min-w-max">
 | 
				
			||||||
 | 
					              <button type="button" *ngFor="let m of viewModes" (click)="setViewMode(m)" [class.bg-surface1]="state.viewMode() === m" class="w-full text-left px-3 py-2 text-xs hover:bg-surface1 dark:hover:bg-surface2 transition-colors">{{ getViewModeLabel(m) }}</button>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          <!-- Count -->
 | 
				
			||||||
 | 
					          <div class="flex items-center gap-1 text-xs text-muted">
 | 
				
			||||||
 | 
					            <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="9"/></svg>
 | 
				
			||||||
 | 
					            {{ visibleNotes().length }}
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <!-- Virtual scroll viewport -->
 | 
					      <!-- Virtual scroll viewport -->
 | 
				
			||||||
@ -50,13 +93,50 @@ import { takeUntil } from 'rxjs/operators';
 | 
				
			|||||||
          
 | 
					          
 | 
				
			||||||
          <ul class="notes-list">
 | 
					          <ul class="notes-list">
 | 
				
			||||||
            <!-- Virtual items -->
 | 
					            <!-- Virtual items -->
 | 
				
			||||||
            <li *cdkVirtualFor="let note of paginatedNotes(); trackBy: trackByFn"
 | 
					            <li *cdkVirtualFor="let note of visibleNotes(); trackBy: trackByFn"
 | 
				
			||||||
                class="note-row cursor-pointer"
 | 
					                class="note-row note-card group cursor-pointer relative"
 | 
				
			||||||
 | 
					                [ngClass]="getListItemClasses()"
 | 
				
			||||||
 | 
					                [ngStyle]="getNoteGradientStyleById(note.id)"
 | 
				
			||||||
 | 
					                [attr.data-note-id]="note.id"
 | 
				
			||||||
                [class.active]="(selectedId() ?? selectedNoteId()) === note.id"
 | 
					                [class.active]="(selectedId() ?? selectedNoteId()) === note.id"
 | 
				
			||||||
                (click)="selectNote(note)">
 | 
					                (click)="selectNote(note)"
 | 
				
			||||||
              <div class="note-inner">
 | 
					                (contextmenu)="openContextMenu($event, note.id)">
 | 
				
			||||||
                <div class="title text-sm truncate">{{ note.title }}</div>
 | 
					
 | 
				
			||||||
                <div class="meta text-xs truncate">{{ note.filePath }}</div>
 | 
					              <!-- Action Buttons (hover reveal) -->
 | 
				
			||||||
 | 
					              <div class="note-card-actions absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-10">
 | 
				
			||||||
 | 
					                <button type="button" class="action-btn edit inline-flex items-center justify-center w-7 h-7 rounded-lg bg-card/80 dark:bg-main/80 hover:bg-surface1 dark:hover:bg-surface2 transition-colors backdrop-blur-sm" title="Éditer la note" (click)="$event.stopPropagation(); editNote(note)">
 | 
				
			||||||
 | 
					                  <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
 | 
				
			||||||
 | 
					                </button>
 | 
				
			||||||
 | 
					                <button type="button" class="action-btn delete inline-flex items-center justify-center w-7 h-7 rounded-lg bg-card/80 dark:bg-main/80 hover:bg-red-100 dark:hover:bg-red-950 transition-colors backdrop-blur-sm" title="Supprimer la note" (click)="$event.stopPropagation(); openDeleteWarning(note)">
 | 
				
			||||||
 | 
					                  <svg class="w-4 h-4 text-red-600 dark:text-red-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
 | 
				
			||||||
 | 
					                </button>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					              <!-- Compact View -->
 | 
				
			||||||
 | 
					              <div *ngIf="state.viewMode() === 'compact'" class="note-inner flex items-center gap-2">
 | 
				
			||||||
 | 
					                <span class="note-color-dot flex-shrink-0" [style.backgroundColor]="getNoteColorById(note.id)" aria-hidden="true"></span>
 | 
				
			||||||
 | 
					                <span class="flex-shrink-0" title="Type">{{ typeIcon(note.filePath) }}</span>
 | 
				
			||||||
 | 
					                <div class="title text-xs truncate">{{ note.title }}</div>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					              <!-- Comfortable View (default) -->
 | 
				
			||||||
 | 
					              <div *ngIf="state.viewMode() === 'comfortable'" class="note-inner flex items-start gap-2">
 | 
				
			||||||
 | 
					                <span class="note-color-dot flex-shrink-0 mt-1" [style.backgroundColor]="getNoteColorById(note.id)" aria-hidden="true"></span>
 | 
				
			||||||
 | 
					                <span class="flex-shrink-0 mt-0.5" title="Type">{{ typeIcon(note.filePath) }}</span>
 | 
				
			||||||
 | 
					                <div class="min-w-0 flex-1">
 | 
				
			||||||
 | 
					                  <div class="title text-sm truncate">{{ note.title }}</div>
 | 
				
			||||||
 | 
					                  <div class="meta text-xs truncate">{{ note.filePath }}</div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					              <!-- Detailed View -->
 | 
				
			||||||
 | 
					              <div *ngIf="state.viewMode() === 'detailed'" class="note-inner flex items-start gap-2 space-y-0">
 | 
				
			||||||
 | 
					                <span class="note-color-dot flex-shrink-0 mt-1" [style.backgroundColor]="getNoteColorById(note.id)" aria-hidden="true"></span>
 | 
				
			||||||
 | 
					                <span class="flex-shrink-0 mt-0.5" title="Type">{{ typeIcon(note.filePath) }}</span>
 | 
				
			||||||
 | 
					                <div class="min-w-0 flex-1 space-y-1.5">
 | 
				
			||||||
 | 
					                  <div class="title text-sm truncate">{{ note.title }}</div>
 | 
				
			||||||
 | 
					                  <div class="meta text-xs truncate">{{ note.filePath }}</div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
              </div>
 | 
					              </div>
 | 
				
			||||||
            </li>
 | 
					            </li>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -81,6 +161,28 @@ import { takeUntil } from 'rxjs/operators';
 | 
				
			|||||||
        </cdk-virtual-scroll-viewport>
 | 
					        </cdk-virtual-scroll-viewport>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- Note Context Menu -->
 | 
				
			||||||
 | 
					    <app-note-context-menu
 | 
				
			||||||
 | 
					      [x]="contextMenu.x()"
 | 
				
			||||||
 | 
					      [y]="contextMenu.y()"
 | 
				
			||||||
 | 
					      [visible]="contextMenu.visible()"
 | 
				
			||||||
 | 
					      [note]="contextMenu.targetNote()"
 | 
				
			||||||
 | 
					      (action)="onContextMenuAction($event)"
 | 
				
			||||||
 | 
					      (color)="onContextMenuColor($event)"
 | 
				
			||||||
 | 
					      (closed)="contextMenu.close()">
 | 
				
			||||||
 | 
					    </app-note-context-menu>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- Delete Warning Modal -->
 | 
				
			||||||
 | 
					    <app-warning-panel
 | 
				
			||||||
 | 
					      [visible]="deleteWarningOpen()"
 | 
				
			||||||
 | 
					      [title]="'Delete this note?'"
 | 
				
			||||||
 | 
					      [message]="'The note will be moved to the trash folder and can be restored later.'"
 | 
				
			||||||
 | 
					      [confirmText]="'Delete'"
 | 
				
			||||||
 | 
					      [cancelText]="'Cancel'"
 | 
				
			||||||
 | 
					      [confirmColor]="'danger'"
 | 
				
			||||||
 | 
					      (delete)="confirmDelete()"
 | 
				
			||||||
 | 
					      (cancel)="closeDeleteWarning()"></app-warning-panel>
 | 
				
			||||||
  `,
 | 
					  `,
 | 
				
			||||||
  styles: [`
 | 
					  styles: [`
 | 
				
			||||||
    :host {
 | 
					    :host {
 | 
				
			||||||
@ -178,11 +280,35 @@ import { takeUntil } from 'rxjs/operators';
 | 
				
			|||||||
    :host-context(html.dark) .meta { color: var(--meta-color, #94a3b8); opacity: 0.9; }
 | 
					    :host-context(html.dark) .meta { color: var(--meta-color, #94a3b8); opacity: 0.9; }
 | 
				
			||||||
    .excerpt { color: var(--meta-color); opacity: 0.75; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; text-overflow: ellipsis; }
 | 
					    .excerpt { color: var(--meta-color); opacity: 0.75; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; text-overflow: ellipsis; }
 | 
				
			||||||
    :host::before { z-index: 0; }
 | 
					    :host::before { z-index: 0; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /* Action buttons container */
 | 
				
			||||||
 | 
					    .action-buttons { position: relative; display: flex; align-items: center; justify-content: space-between; width: 100%; gap: 0.5rem; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /* Enhanced note card with color indicator and action buttons */
 | 
				
			||||||
 | 
					    .note-card { transition: all 0.3s ease-in-out; background-repeat: no-repeat; background-size: 100% 120px; background-position: top center; }
 | 
				
			||||||
 | 
					    .note-card:hover { transform: translateY(-1px); }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /* Color dot indicator */
 | 
				
			||||||
 | 
					    .note-color-dot { width: 8px; height: 8px; border-radius: 50%; box-shadow: 0 0 0 1px color-mix(in oklab, var(--text-main) 15%, transparent 85%); transition: all 0.2s ease-in-out; }
 | 
				
			||||||
 | 
					    .note-row:hover .note-color-dot { box-shadow: 0 0 0 2px color-mix(in oklab, var(--text-main) 25%, transparent 75%); }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /* Action buttons */
 | 
				
			||||||
 | 
					    .note-card-actions { pointer-events: auto; }
 | 
				
			||||||
 | 
					    .action-btn { display: inline-flex; align-items: center; justify-content: center; cursor: pointer; font-size: 0; }
 | 
				
			||||||
 | 
					    .action-btn.edit { color: var(--primary, #3b82f6); }
 | 
				
			||||||
 | 
					    .action-btn.delete { color: #dc2626; }
 | 
				
			||||||
 | 
					    :host-context(html.dark) .action-btn.delete { color: #ef4444; }
 | 
				
			||||||
  `]
 | 
					  `]
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
export class PaginatedNotesListComponent implements OnInit, OnDestroy {
 | 
					export class PaginatedNotesListComponent implements OnInit, OnDestroy {
 | 
				
			||||||
  private paginationService = inject(PaginationService);
 | 
					  private paginationService = inject(PaginationService);
 | 
				
			||||||
  private store = inject(TagFilterStore);
 | 
					  private store = inject(TagFilterStore);
 | 
				
			||||||
 | 
					  private vault = inject(VaultService);
 | 
				
			||||||
 | 
					  private fileTypes = inject(FileTypeDetectorService);
 | 
				
			||||||
 | 
					  readonly state = inject(NotesListStateService);
 | 
				
			||||||
 | 
					  readonly filter = inject(FilterService);
 | 
				
			||||||
 | 
					  readonly contextMenu = inject(NoteContextMenuService);
 | 
				
			||||||
 | 
					  private editorState = inject(EditorStateService);
 | 
				
			||||||
  private destroy$ = new Subject<void>();
 | 
					  private destroy$ = new Subject<void>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @ViewChild(CdkVirtualScrollViewport) viewport?: CdkVirtualScrollViewport;
 | 
					  @ViewChild(CdkVirtualScrollViewport) viewport?: CdkVirtualScrollViewport;
 | 
				
			||||||
@ -193,6 +319,7 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
 | 
				
			|||||||
  tagFilter = input<string | null>(null);
 | 
					  tagFilter = input<string | null>(null);
 | 
				
			||||||
  quickLinkFilter = input<'favoris' | 'publish' | 'draft' | 'template' | 'task' | 'private' | 'archive' | null>(null);
 | 
					  quickLinkFilter = input<'favoris' | 'publish' | 'draft' | 'template' | 'task' | 'private' | 'archive' | null>(null);
 | 
				
			||||||
  selectedId = input<string | null>(null);
 | 
					  selectedId = input<string | null>(null);
 | 
				
			||||||
 | 
					  kindFilter = input<'image' | 'video' | 'pdf' | 'markdown' | 'excalidraw' | 'code' | 'all' | null>(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // Outputs
 | 
					  // Outputs
 | 
				
			||||||
  @Output() openNote = new EventEmitter<string>();
 | 
					  @Output() openNote = new EventEmitter<string>();
 | 
				
			||||||
@ -203,6 +330,14 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
 | 
				
			|||||||
  private q = signal('');
 | 
					  private q = signal('');
 | 
				
			||||||
  selectedNoteId = signal<string | null>(null);
 | 
					  selectedNoteId = signal<string | null>(null);
 | 
				
			||||||
  activeTag = signal<string | null>(null);
 | 
					  activeTag = signal<string | null>(null);
 | 
				
			||||||
 | 
					  sortMenuOpen = signal<boolean>(false);
 | 
				
			||||||
 | 
					  viewModeMenuOpen = signal<boolean>(false);
 | 
				
			||||||
 | 
					  readonly sortOptions: SortBy[] = ['title', 'created', 'updated'];
 | 
				
			||||||
 | 
					  readonly viewModes: ViewMode[] = ['compact', 'comfortable', 'detailed'];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Delete warning modal state
 | 
				
			||||||
 | 
					  deleteWarningOpen = signal<boolean>(false);
 | 
				
			||||||
 | 
					  private deleteTargetId: string | null = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // Pagination state
 | 
					  // Pagination state
 | 
				
			||||||
  paginatedNotes = this.paginationService.allItems;
 | 
					  paginatedNotes = this.paginationService.allItems;
 | 
				
			||||||
@ -211,9 +346,167 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
 | 
				
			|||||||
  totalLoaded = this.paginationService.totalLoaded;
 | 
					  totalLoaded = this.paginationService.totalLoaded;
 | 
				
			||||||
  canLoadMore = this.paginationService.canLoadMore;
 | 
					  canLoadMore = this.paginationService.canLoadMore;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Visible notes with fallback and filters
 | 
				
			||||||
 | 
					  visibleNotes = computed<NoteMetadata[]>(() => {
 | 
				
			||||||
 | 
					    let items = this.paginatedNotes();
 | 
				
			||||||
 | 
					    let usedFallback = false;
 | 
				
			||||||
 | 
					    const vaultNotes = (() => {
 | 
				
			||||||
 | 
					      try { return this.vault.allNotes() || []; } catch { return []; }
 | 
				
			||||||
 | 
					    })();
 | 
				
			||||||
 | 
					    const byId = new Map<string, any>(vaultNotes.map(n => [n.id, n]));
 | 
				
			||||||
 | 
					    if (!items || items.length === 0) {
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        const all = this.vault.allNotes();
 | 
				
			||||||
 | 
					        items = (all || []).map(n => ({
 | 
				
			||||||
 | 
					          id: n.id,
 | 
				
			||||||
 | 
					          title: n.title,
 | 
				
			||||||
 | 
					          filePath: n.filePath,
 | 
				
			||||||
 | 
					          createdAt: n.createdAt as any,
 | 
				
			||||||
 | 
					          updatedAt: (n.updatedAt as any) || (n.mtime ? new Date(n.mtime).toISOString() : '')
 | 
				
			||||||
 | 
					        }));
 | 
				
			||||||
 | 
					        usedFallback = true;
 | 
				
			||||||
 | 
					      } catch {
 | 
				
			||||||
 | 
					        items = [];
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Folder filter
 | 
				
			||||||
 | 
					    const folder = (this.folderFilter() || '').toLowerCase().replace(/^\/+|\/+$/g, '');
 | 
				
			||||||
 | 
					    if (folder) {
 | 
				
			||||||
 | 
					      if (folder === '.trash') {
 | 
				
			||||||
 | 
					        items = items.filter(n => {
 | 
				
			||||||
 | 
					          const fp = (n.filePath || '').toLowerCase().replace(/\\/g, '/');
 | 
				
			||||||
 | 
					          return fp.startsWith('.trash/') || fp.includes('/.trash/');
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        items = items.filter(n => {
 | 
				
			||||||
 | 
					          const op = (n.filePath || '').toLowerCase().replace(/^\/+|\/+$/g, '');
 | 
				
			||||||
 | 
					          return op === folder || op.startsWith(folder + '/');
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      // Exclude trash by default
 | 
				
			||||||
 | 
					      items = items.filter(n => {
 | 
				
			||||||
 | 
					        const fp = (n.filePath || '').toLowerCase().replace(/\\/g, '/');
 | 
				
			||||||
 | 
					        return !fp.startsWith('.trash/') && !fp.includes('/.trash/');
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Kind filters (FilterService multi-kinds first; fallback to single kindFilter)
 | 
				
			||||||
 | 
					    const kinds = this.filter.kinds();
 | 
				
			||||||
 | 
					    const urlKind = this.kindFilter();
 | 
				
			||||||
 | 
					    let allowedKinds = new Set<string>(kinds.length > 0 ? kinds : (urlKind && urlKind !== 'all' ? [urlKind] : []));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Folder/Trash views must show all types unless quick/tag constrain to markdown
 | 
				
			||||||
 | 
					    const folderActive = !!folder;
 | 
				
			||||||
 | 
					    const quickActive = !!this.quickLinkFilter();
 | 
				
			||||||
 | 
					    const tagActive = !!(this.tagFilter() || '').trim() || this.filter.tags().length > 0;
 | 
				
			||||||
 | 
					    if (folderActive && !quickActive && !tagActive) {
 | 
				
			||||||
 | 
					      allowedKinds = new Set<string>(); // no restriction in folder/trash
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (allowedKinds.size > 0) {
 | 
				
			||||||
 | 
					      items = items.filter(n => Array.from(allowedKinds).some(k => this.matchesKind(n.filePath, k as any)));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Query filtering (always apply client-side as extra guard)
 | 
				
			||||||
 | 
					    const q = (this.q() || '').toLowerCase().trim();
 | 
				
			||||||
 | 
					    if (q) {
 | 
				
			||||||
 | 
					      items = items.filter(n => (n.title || '').toLowerCase().includes(q) || (n.filePath || '').toLowerCase().includes(q));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Tag and Quick Link filters using vault metadata when available
 | 
				
			||||||
 | 
					    const urlTag = (this.tagFilter() || '').toLowerCase();
 | 
				
			||||||
 | 
					    const localTags = this.filter.tags().map(t => (t || '').toLowerCase());
 | 
				
			||||||
 | 
					    const quick = this.quickLinkFilter();
 | 
				
			||||||
 | 
					    if (urlTag || localTags.length > 0) {
 | 
				
			||||||
 | 
					      items = items.filter(n => {
 | 
				
			||||||
 | 
					        const full = byId.get(n.id);
 | 
				
			||||||
 | 
					        const ntags: string[] = Array.isArray(full?.tags) ? full.tags.map((t: string) => (t || '').toLowerCase()) : [];
 | 
				
			||||||
 | 
					        if (urlTag && !ntags.includes(urlTag)) return false;
 | 
				
			||||||
 | 
					        for (const t of localTags) { if (!ntags.includes(t)) return false; }
 | 
				
			||||||
 | 
					        // Tags view must show markdown only
 | 
				
			||||||
 | 
					        return this.matchesKind(n.filePath, 'markdown');
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    if (quick) {
 | 
				
			||||||
 | 
					      items = items.filter(n => {
 | 
				
			||||||
 | 
					        const full = byId.get(n.id);
 | 
				
			||||||
 | 
					        const fm = full?.frontmatter || {};
 | 
				
			||||||
 | 
					        return fm[quick] === true && this.matchesKind(n.filePath, 'markdown');
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // If allowed kinds include any non-markdown type OR no kinds selected at all (default 'all'),
 | 
				
			||||||
 | 
					    // ensure those files appear even if pagination didn't include them (server may return only markdown)
 | 
				
			||||||
 | 
					    const needMergeForKinds = (allowedKinds.size > 0 && Array.from(allowedKinds).some(k => k !== 'markdown'))
 | 
				
			||||||
 | 
					      || (allowedKinds.size === 0 && !quick && !tagActive); // default 'all' and no quick/tag constraint
 | 
				
			||||||
 | 
					    if (needMergeForKinds) {
 | 
				
			||||||
 | 
					      const present = new Set(items.map(n => n.id));
 | 
				
			||||||
 | 
					      for (const full of vaultNotes) {
 | 
				
			||||||
 | 
					        const t = this.fileTypes.getViewerType(full.filePath, full.rawContent ?? full.content ?? '');
 | 
				
			||||||
 | 
					        const allowByKind = allowedKinds.size === 0 ? true : allowedKinds.has(t);
 | 
				
			||||||
 | 
					        if (allowByKind && !present.has(full.id)) {
 | 
				
			||||||
 | 
					          // Apply same folder filter and tag/quick constraints
 | 
				
			||||||
 | 
					          const fp = (full.filePath || '').toLowerCase().replace(/\\/g, '/');
 | 
				
			||||||
 | 
					          const op = (full.filePath || '').toLowerCase().replace(/^\/+|\/+$/g, '');
 | 
				
			||||||
 | 
					          const includeByFolder = folder
 | 
				
			||||||
 | 
					            ? (folder === '.trash'
 | 
				
			||||||
 | 
					                ? (fp.startsWith('.trash/') || fp.includes('/.trash/'))
 | 
				
			||||||
 | 
					                : (op === folder || op.startsWith(folder + '/')))
 | 
				
			||||||
 | 
					            : (!fp.startsWith('.trash/') && !fp.includes('/.trash/'));
 | 
				
			||||||
 | 
					          if (!includeByFolder) continue;
 | 
				
			||||||
 | 
					          const ntags: string[] = Array.isArray(full.tags) ? full.tags.map((x: string) => (x || '').toLowerCase()) : [];
 | 
				
			||||||
 | 
					          if (urlTag && !ntags.includes(urlTag)) continue;
 | 
				
			||||||
 | 
					          let okLocal = true; for (const t of localTags) { if (!ntags.includes(t)) { okLocal = false; break; } }
 | 
				
			||||||
 | 
					          if (!okLocal) continue;
 | 
				
			||||||
 | 
					          if (quick) {
 | 
				
			||||||
 | 
					            const fm = full.frontmatter || {};
 | 
				
			||||||
 | 
					            if (fm[quick] !== true) continue;
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					          if (q) {
 | 
				
			||||||
 | 
					            const titleLc = (full.title || '').toLowerCase();
 | 
				
			||||||
 | 
					            const pathLc = (full.filePath || '').toLowerCase();
 | 
				
			||||||
 | 
					            if (!titleLc.includes(q) && !pathLc.includes(q)) continue;
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					          items.push({ id: full.id, title: full.title, filePath: full.filePath, createdAt: (full as any).createdAt, updatedAt: (full as any).updatedAt || (full.mtime ? new Date(full.mtime).toISOString() : '') });
 | 
				
			||||||
 | 
					          present.add(full.id);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Sorting (title/created/updated) like old list
 | 
				
			||||||
 | 
					    const parseDate = (s?: string) => (s ? Date.parse(s) : 0) || 0;
 | 
				
			||||||
 | 
					    const sortBy = this.state.sortBy();
 | 
				
			||||||
 | 
					    items = [...items].sort((a, b) => {
 | 
				
			||||||
 | 
					      switch (sortBy) {
 | 
				
			||||||
 | 
					        case 'title':
 | 
				
			||||||
 | 
					          return (a.title || '').localeCompare(b.title || '');
 | 
				
			||||||
 | 
					        case 'created':
 | 
				
			||||||
 | 
					          return parseDate(b.createdAt) - parseDate(a.createdAt);
 | 
				
			||||||
 | 
					        case 'updated':
 | 
				
			||||||
 | 
					        default:
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            const mb = byId.get(b.id)?.mtime; const ma = byId.get(a.id)?.mtime;
 | 
				
			||||||
 | 
					            const ub = parseDate(b.updatedAt) || (mb ? Number(mb) : 0);
 | 
				
			||||||
 | 
					            const ua = parseDate(a.updatedAt) || (ma ? Number(ma) : 0);
 | 
				
			||||||
 | 
					            return ub - ua;
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return items;
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // Effects
 | 
					  // Effects
 | 
				
			||||||
  private syncQuery = effect(() => {
 | 
					  private syncQuery = effect(() => {
 | 
				
			||||||
    this.q.set(this.query() || '');
 | 
					    this.q.set(this.query() || '');
 | 
				
			||||||
 | 
					    // If external query changes (e.g., URL/state), refresh pagination to match
 | 
				
			||||||
 | 
					    const current = this.paginationService.getSearchTerm();
 | 
				
			||||||
 | 
					    const next = this.query() || '';
 | 
				
			||||||
 | 
					    if (current !== next) {
 | 
				
			||||||
 | 
					      this.paginationService.search(next);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private syncTagFromStore = effect(() => {
 | 
					  private syncTagFromStore = effect(() => {
 | 
				
			||||||
@ -226,8 +519,8 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
 | 
				
			|||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  ngOnInit() {
 | 
					  ngOnInit() {
 | 
				
			||||||
    // Load initial page
 | 
					    // Load initial page with incoming query
 | 
				
			||||||
    this.paginationService.loadInitial();
 | 
					    this.paginationService.loadInitial(this.query() || '');
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  ngOnDestroy() {
 | 
					  ngOnDestroy() {
 | 
				
			||||||
@ -237,7 +530,7 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  // Handle virtual scroll
 | 
					  // Handle virtual scroll
 | 
				
			||||||
  onScroll(index: number) {
 | 
					  onScroll(index: number) {
 | 
				
			||||||
    const items = this.paginatedNotes();
 | 
					    const items = this.visibleNotes();
 | 
				
			||||||
    // Load more when approaching the end (20 items before the end)
 | 
					    // Load more when approaching the end (20 items before the end)
 | 
				
			||||||
    if (index > items.length - 20 && this.canLoadMore()) {
 | 
					    if (index > items.length - 20 && this.canLoadMore()) {
 | 
				
			||||||
      this.paginationService.loadNextPage();
 | 
					      this.paginationService.loadNextPage();
 | 
				
			||||||
@ -258,6 +551,11 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
 | 
				
			|||||||
    this.paginationService.search(v);
 | 
					    this.paginationService.search(v);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  onSearchEnter(): void {
 | 
				
			||||||
 | 
					    const first = this.visibleNotes()[0];
 | 
				
			||||||
 | 
					    if (first) this.openNote.emit(first.id);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // Clear tag filter
 | 
					  // Clear tag filter
 | 
				
			||||||
  clearTagFilter(): void {
 | 
					  clearTagFilter(): void {
 | 
				
			||||||
    this.activeTag.set(null);
 | 
					    this.activeTag.set(null);
 | 
				
			||||||
@ -284,4 +582,144 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
 | 
				
			|||||||
    };
 | 
					    };
 | 
				
			||||||
    return displays[quickLink] || null;
 | 
					    return displays[quickLink] || null;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Helpers
 | 
				
			||||||
 | 
					  private matchesKind(filePath: string, kind: 'image' | 'video' | 'pdf' | 'markdown' | 'excalidraw' | 'code'): boolean {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const t = this.fileTypes.getViewerType(filePath, '');
 | 
				
			||||||
 | 
					      return t === kind;
 | 
				
			||||||
 | 
					    } catch {
 | 
				
			||||||
 | 
					      return true;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // UI helpers
 | 
				
			||||||
 | 
					  getListItemClasses(): string {
 | 
				
			||||||
 | 
					    const mode = this.state.viewMode();
 | 
				
			||||||
 | 
					    if (mode === 'compact') return 'px-3 py-1.5';
 | 
				
			||||||
 | 
					    if (mode === 'detailed') return 'p-3 space-y-1.5';
 | 
				
			||||||
 | 
					    return 'p-3';
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Color and gradient
 | 
				
			||||||
 | 
					  private getFullNoteById(id: string): any | null {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const n = (this.vault as any).getNoteById?.(id);
 | 
				
			||||||
 | 
					      if (n) return n;
 | 
				
			||||||
 | 
					    } catch {}
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const list = this.vault.allNotes() || [];
 | 
				
			||||||
 | 
					      for (const n of list) if ((n as any).id === id) return n;
 | 
				
			||||||
 | 
					    } catch {}
 | 
				
			||||||
 | 
					    return null;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  getNoteColorById(id: string): string {
 | 
				
			||||||
 | 
					    const full = this.getFullNoteById(id);
 | 
				
			||||||
 | 
					    return full?.frontmatter?.color || 'var(--text-muted)';
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  getNoteGradientStyleById(id: string): Record<string, string> | null {
 | 
				
			||||||
 | 
					    const full = this.getFullNoteById(id);
 | 
				
			||||||
 | 
					    const color = full?.frontmatter?.color;
 | 
				
			||||||
 | 
					    if (!color) return null;
 | 
				
			||||||
 | 
					    const hexMatch = /^#([0-9a-fA-F]{6})$/.exec(color);
 | 
				
			||||||
 | 
					    let gradientColor = color;
 | 
				
			||||||
 | 
					    if (hexMatch) {
 | 
				
			||||||
 | 
					      const hex = hexMatch[1];
 | 
				
			||||||
 | 
					      const r = parseInt(hex.slice(0,2), 16);
 | 
				
			||||||
 | 
					      const g = parseInt(hex.slice(2,4), 16);
 | 
				
			||||||
 | 
					      const b = parseInt(hex.slice(4,6), 16);
 | 
				
			||||||
 | 
					      gradientColor = `rgba(${r}, ${g}, ${b}, 0.14)`;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return { backgroundImage: `linear-gradient(to left, ${gradientColor} 0%, transparent 65%)` } as Record<string, string>;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  typeIcon(filePath: string): string {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const t = this.fileTypes.getViewerType(filePath, '');
 | 
				
			||||||
 | 
					      switch (t) {
 | 
				
			||||||
 | 
					        case 'markdown': return '📝';
 | 
				
			||||||
 | 
					        case 'excalidraw': return '✏️';
 | 
				
			||||||
 | 
					        case 'pdf': return '📄';
 | 
				
			||||||
 | 
					        case 'image': return '🖼️';
 | 
				
			||||||
 | 
					        case 'video': return '🎬';
 | 
				
			||||||
 | 
					        case 'code': return '</>';
 | 
				
			||||||
 | 
					        default: return '📎';
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } catch { return '📎'; }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Sort/View menus
 | 
				
			||||||
 | 
					  toggleSortMenu(): void { this.sortMenuOpen.set(!this.sortMenuOpen()); this.viewModeMenuOpen.set(false); }
 | 
				
			||||||
 | 
					  toggleViewModeMenu(): void { this.viewModeMenuOpen.set(!this.viewModeMenuOpen()); this.sortMenuOpen.set(false); }
 | 
				
			||||||
 | 
					  setSortBy(sort: SortBy): void { this.state.setSortBy(sort); this.sortMenuOpen.set(false); }
 | 
				
			||||||
 | 
					  setViewMode(mode: ViewMode): void { this.state.setViewMode(mode); this.viewModeMenuOpen.set(false); }
 | 
				
			||||||
 | 
					  getSortLabel(sort: SortBy): string {
 | 
				
			||||||
 | 
					    const labels: Record<SortBy, string> = { title: 'Titre', created: 'Date création', updated: 'Date modification' };
 | 
				
			||||||
 | 
					    return labels[sort];
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  getViewModeLabel(mode: ViewMode): string {
 | 
				
			||||||
 | 
					    const labels: Record<ViewMode, string> = { compact: 'Compact', comfortable: 'Confortable', detailed: 'Détaillé' };
 | 
				
			||||||
 | 
					    return labels[mode];
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Context menu and delete
 | 
				
			||||||
 | 
					  openContextMenu(event: MouseEvent, noteId: string) {
 | 
				
			||||||
 | 
					    event.preventDefault(); event.stopPropagation();
 | 
				
			||||||
 | 
					    const full = this.getFullNoteById(noteId);
 | 
				
			||||||
 | 
					    if (full) this.contextMenu.openForNote(full, { x: event.clientX, y: event.clientY });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async onContextMenuAction(action: string) {
 | 
				
			||||||
 | 
					    const note = this.contextMenu.targetNote();
 | 
				
			||||||
 | 
					    if (!note) return;
 | 
				
			||||||
 | 
					    switch (action) {
 | 
				
			||||||
 | 
					      case 'duplicate': await this.contextMenu.duplicateNote(note); break;
 | 
				
			||||||
 | 
					      case 'share': await this.contextMenu.shareNote(note); break;
 | 
				
			||||||
 | 
					      case 'fullscreen': this.contextMenu.openFullScreen(note); break;
 | 
				
			||||||
 | 
					      case 'copy-link': await this.contextMenu.copyInternalLink(note); break;
 | 
				
			||||||
 | 
					      case 'favorite': await this.contextMenu.toggleFavorite(note); break;
 | 
				
			||||||
 | 
					      case 'info': this.contextMenu.showPageInfo(note); break;
 | 
				
			||||||
 | 
					      case 'readonly': await this.contextMenu.toggleReadOnly(note); break;
 | 
				
			||||||
 | 
					      case 'delete': this.openDeleteWarningById(note.id); break;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async onContextMenuColor(color: string) {
 | 
				
			||||||
 | 
					    const note = this.contextMenu.targetNote();
 | 
				
			||||||
 | 
					    if (!note) return;
 | 
				
			||||||
 | 
					    await this.contextMenu.changeNoteColor(note, color);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  openDeleteWarning(note: NoteMetadata) { this.openDeleteWarningById(note.id); }
 | 
				
			||||||
 | 
					  openDeleteWarningById(id: string) { this.deleteTargetId = id; this.deleteWarningOpen.set(true); }
 | 
				
			||||||
 | 
					  closeDeleteWarning() { this.deleteWarningOpen.set(false); this.deleteTargetId = null; }
 | 
				
			||||||
 | 
					  async confirmDelete() {
 | 
				
			||||||
 | 
					    const id = this.deleteTargetId; if (!id) { this.closeDeleteWarning(); return; }
 | 
				
			||||||
 | 
					    const full = this.getFullNoteById(id); if (!full) { this.closeDeleteWarning(); return; }
 | 
				
			||||||
 | 
					    try { await this.contextMenu.deleteNoteConfirmed(full); this.closeDeleteWarning(); this.contextMenu.close(); } catch {}
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Edit
 | 
				
			||||||
 | 
					  editNote(note: NoteMetadata): void {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const full = this.getFullNoteById(note.id);
 | 
				
			||||||
 | 
					      if (full?.filePath) {
 | 
				
			||||||
 | 
					        const content = (full as any).rawContent ?? full.content ?? '';
 | 
				
			||||||
 | 
					        this.editorState.enterEditMode(full.filePath, content);
 | 
				
			||||||
 | 
					        this.openNote.emit(note.id);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } catch { this.openNote.emit(note.id); }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Scroll selected into view
 | 
				
			||||||
 | 
					  private scrollToSelectedEffect = effect(() => {
 | 
				
			||||||
 | 
					    const id = this.selectedId();
 | 
				
			||||||
 | 
					    if (!id || !this.viewport) return;
 | 
				
			||||||
 | 
					    const idx = this.visibleNotes().findIndex(n => n.id === id);
 | 
				
			||||||
 | 
					    if (idx >= 0) {
 | 
				
			||||||
 | 
					      try { this.viewport.scrollToIndex(idx, 'smooth'); } catch {}
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -11,7 +11,7 @@ import { AppBottomNavigationComponent } from '../../features/bottom-nav/app-bott
 | 
				
			|||||||
import { AppSidebarDrawerComponent } from '../../features/sidebar/app-sidebar-drawer.component';
 | 
					import { AppSidebarDrawerComponent } from '../../features/sidebar/app-sidebar-drawer.component';
 | 
				
			||||||
import { AppTocOverlayComponent } from '../../features/note-view/app-toc-overlay.component';
 | 
					import { AppTocOverlayComponent } from '../../features/note-view/app-toc-overlay.component';
 | 
				
			||||||
import { SwipeNavDirective } from '../../shared/directives/swipe-nav.directive';
 | 
					import { SwipeNavDirective } from '../../shared/directives/swipe-nav.directive';
 | 
				
			||||||
import { NotesListComponent } from '../../features/list/notes-list.component';
 | 
					import { PaginatedNotesListComponent } from '../../features/list/paginated-notes-list.component';
 | 
				
			||||||
import { NimbusSidebarComponent } from '../../features/sidebar/nimbus-sidebar.component';
 | 
					import { NimbusSidebarComponent } from '../../features/sidebar/nimbus-sidebar.component';
 | 
				
			||||||
import { QuickLinksComponent } from '../../features/quick-links/quick-links.component';
 | 
					import { QuickLinksComponent } from '../../features/quick-links/quick-links.component';
 | 
				
			||||||
import { ScrollableOverlayDirective } from '../../shared/overlay-scrollbar/scrollable-overlay.directive';
 | 
					import { ScrollableOverlayDirective } from '../../shared/overlay-scrollbar/scrollable-overlay.directive';
 | 
				
			||||||
@ -30,7 +30,7 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
 | 
				
			|||||||
@Component({
 | 
					@Component({
 | 
				
			||||||
  selector: 'app-shell-nimbus-layout',
 | 
					  selector: 'app-shell-nimbus-layout',
 | 
				
			||||||
  standalone: true,
 | 
					  standalone: true,
 | 
				
			||||||
  imports: [CommonModule, FileExplorerComponent, NoteViewerComponent, AppBottomNavigationComponent, AppSidebarDrawerComponent, AppTocOverlayComponent, SwipeNavDirective, NotesListComponent, NimbusSidebarComponent, QuickLinksComponent, ScrollableOverlayDirective, MarkdownPlaygroundComponent, TestsPanelComponent, TestExcalidrawPageComponent, ParametersPage, AboutPanelComponent, NoteInfoModalComponent, InPageSearchOverlayComponent],
 | 
					  imports: [CommonModule, FileExplorerComponent, NoteViewerComponent, AppBottomNavigationComponent, AppSidebarDrawerComponent, AppTocOverlayComponent, SwipeNavDirective, PaginatedNotesListComponent, NimbusSidebarComponent, QuickLinksComponent, ScrollableOverlayDirective, MarkdownPlaygroundComponent, TestsPanelComponent, TestExcalidrawPageComponent, ParametersPage, AboutPanelComponent, NoteInfoModalComponent, InPageSearchOverlayComponent],
 | 
				
			||||||
  template: `
 | 
					  template: `
 | 
				
			||||||
    <div class="relative h-screen flex flex-col bg-card dark:bg-main text-main dark:text-gray-100" [style.--sidebar-width.px]="isSidebarOpen ? leftSidebarWidth : 64">
 | 
					    <div class="relative h-screen flex flex-col bg-card dark:bg-main text-main dark:text-gray-100" [style.--sidebar-width.px]="isSidebarOpen ? leftSidebarWidth : 64">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -170,8 +170,7 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
 | 
				
			|||||||
        <!-- Center List -->
 | 
					        <!-- Center List -->
 | 
				
			||||||
        <section class="border-r border-border dark:border-gray-800 overflow-hidden" [style.width.px]="centerPanelWidth">
 | 
					        <section class="border-r border-border dark:border-gray-800 overflow-hidden" [style.width.px]="centerPanelWidth">
 | 
				
			||||||
          <div class="h-full flex flex-col">
 | 
					          <div class="h-full flex flex-col">
 | 
				
			||||||
            <app-notes-list class="flex-1"
 | 
					            <app-paginated-notes-list class="flex-1"
 | 
				
			||||||
              [notes]="vault.allNotes()"
 | 
					 | 
				
			||||||
              [folderFilter]="folderFilter"
 | 
					              [folderFilter]="folderFilter"
 | 
				
			||||||
              [tagFilter]="tagFilter"
 | 
					              [tagFilter]="tagFilter"
 | 
				
			||||||
              [quickLinkFilter]="quickLinkFilter"
 | 
					              [quickLinkFilter]="quickLinkFilter"
 | 
				
			||||||
@ -181,8 +180,6 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
 | 
				
			|||||||
              (openNote)="onOpenNote($event)"
 | 
					              (openNote)="onOpenNote($event)"
 | 
				
			||||||
              (queryChange)="onQueryChange($event)"
 | 
					              (queryChange)="onQueryChange($event)"
 | 
				
			||||||
              (clearQuickLinkFilter)="onClearQuickLinkFilter()"
 | 
					              (clearQuickLinkFilter)="onClearQuickLinkFilter()"
 | 
				
			||||||
              (noteCreated)="onNoteCreated($event)"
 | 
					 | 
				
			||||||
              (noteCreatedAndSelected)="onNoteCreatedAndSelected($event)"
 | 
					 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        </section>
 | 
					        </section>
 | 
				
			||||||
@ -259,7 +256,17 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
 | 
				
			|||||||
            <app-file-explorer [nodes]="effectiveFileTree" [selectedNoteId]="selectedNoteId" [foldersOnly]="true" [quickLinkFilter]="quickLinkFilter" (folderSelected)="onFolderSelected($event)" (fileSelected)="onOpenNote($event)"></app-file-explorer>
 | 
					            <app-file-explorer [nodes]="effectiveFileTree" [selectedNoteId]="selectedNoteId" [foldersOnly]="true" [quickLinkFilter]="quickLinkFilter" (folderSelected)="onFolderSelected($event)" (fileSelected)="onOpenNote($event)"></app-file-explorer>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
          <div [hidden]="mobileNav.activeTab() !== 'list'" class="h-full overflow-y-auto" appScrollableOverlay>
 | 
					          <div [hidden]="mobileNav.activeTab() !== 'list'" class="h-full overflow-y-auto" appScrollableOverlay>
 | 
				
			||||||
            <app-notes-list [notes]="vault.allNotes()" [folderFilter]="folderFilter" [tagFilter]="tagFilter" [quickLinkFilter]="quickLinkFilter" [kindFilter]="urlState.activeKind()" [query]="listQuery" [selectedId]="selectedNoteId" (queryChange)="onQueryChange($event)" (openNote)="onOpenNote($event)" (clearQuickLinkFilter)="onClearQuickLinkFilter()" (noteCreated)="onNoteCreated($event)" (noteCreatedAndSelected)="onNoteCreatedAndSelected($event)"></app-notes-list>
 | 
					            <app-paginated-notes-list 
 | 
				
			||||||
 | 
					              [folderFilter]="folderFilter"
 | 
				
			||||||
 | 
					              [tagFilter]="tagFilter"
 | 
				
			||||||
 | 
					              [quickLinkFilter]="quickLinkFilter"
 | 
				
			||||||
 | 
					              [kindFilter]="urlState.activeKind()"
 | 
				
			||||||
 | 
					              [query]="listQuery"
 | 
				
			||||||
 | 
					              [selectedId]="selectedNoteId"
 | 
				
			||||||
 | 
					              (queryChange)="onQueryChange($event)"
 | 
				
			||||||
 | 
					              (openNote)="onOpenNote($event)"
 | 
				
			||||||
 | 
					              (clearQuickLinkFilter)="onClearQuickLinkFilter()"
 | 
				
			||||||
 | 
					            ></app-paginated-notes-list>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
          <div [hidden]="mobileNav.activeTab() !== 'page'" class="note-content-area h-full overflow-y-auto" appScrollableOverlay>
 | 
					          <div [hidden]="mobileNav.activeTab() !== 'page'" class="note-content-area h-full overflow-y-auto" appScrollableOverlay>
 | 
				
			||||||
            <app-markdown-playground *ngIf="activeView === 'markdown-playground'"></app-markdown-playground>
 | 
					            <app-markdown-playground *ngIf="activeView === 'markdown-playground'"></app-markdown-playground>
 | 
				
			||||||
@ -292,7 +299,17 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        @if (mobileNav.activeTab() === 'list') {
 | 
					        @if (mobileNav.activeTab() === 'list') {
 | 
				
			||||||
          <div class="h-full flex flex-col overflow-hidden animate-fadeIn">
 | 
					          <div class="h-full flex flex-col overflow-hidden animate-fadeIn">
 | 
				
			||||||
            <app-notes-list class="flex-1" [notes]="vault.allNotes()" [folderFilter]="folderFilter" [tagFilter]="tagFilter" [quickLinkFilter]="quickLinkFilter" [kindFilter]="urlState.activeKind()" [query]="listQuery" [selectedId]="selectedNoteId" (queryChange)="onQueryChange($event)" (openNote)="onNoteSelectedMobile($event)" (clearQuickLinkFilter)="onClearQuickLinkFilter()"></app-notes-list>
 | 
					            <app-paginated-notes-list class="flex-1"
 | 
				
			||||||
 | 
					              [folderFilter]="folderFilter"
 | 
				
			||||||
 | 
					              [tagFilter]="tagFilter"
 | 
				
			||||||
 | 
					              [quickLinkFilter]="quickLinkFilter"
 | 
				
			||||||
 | 
					              [kindFilter]="urlState.activeKind()"
 | 
				
			||||||
 | 
					              [query]="listQuery"
 | 
				
			||||||
 | 
					              [selectedId]="selectedNoteId"
 | 
				
			||||||
 | 
					              (queryChange)="onQueryChange($event)"
 | 
				
			||||||
 | 
					              (openNote)="onNoteSelectedMobile($event)"
 | 
				
			||||||
 | 
					              (clearQuickLinkFilter)="onClearQuickLinkFilter()"
 | 
				
			||||||
 | 
					            ></app-paginated-notes-list>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,12 +1,10 @@
 | 
				
			|||||||
---
 | 
					---
 | 
				
			||||||
titre: Nouvelle note 12
 | 
					titre: "Nouvelle note 12"
 | 
				
			||||||
auteur: Bruno Charest
 | 
					auteur: "Bruno Charest"
 | 
				
			||||||
creation_date: 2025-10-26T14:55:57.870Z
 | 
					creation_date: "2025-10-26T14:55:57.870Z"
 | 
				
			||||||
modification_date: 2025-10-26T10:55:58-04:00
 | 
					modification_date: "2025-10-26T10:55:58-04:00"
 | 
				
			||||||
catégorie: ""
 | 
					aliases: [""]
 | 
				
			||||||
tags: []
 | 
					status: "en-cours"
 | 
				
			||||||
aliases: []
 | 
					 | 
				
			||||||
status: en-cours
 | 
					 | 
				
			||||||
publish: false
 | 
					publish: false
 | 
				
			||||||
favoris: false
 | 
					favoris: false
 | 
				
			||||||
template: false
 | 
					template: false
 | 
				
			||||||
@ -14,4 +12,11 @@ task: false
 | 
				
			|||||||
archive: false
 | 
					archive: false
 | 
				
			||||||
draft: false
 | 
					draft: false
 | 
				
			||||||
private: false
 | 
					private: false
 | 
				
			||||||
 | 
					color: "#22C55E"
 | 
				
			||||||
 | 
					tags:
 | 
				
			||||||
 | 
					  - test2
 | 
				
			||||||
 | 
					  - configuration
 | 
				
			||||||
 | 
					  - bruno
 | 
				
			||||||
 | 
					  - accueil
 | 
				
			||||||
 | 
					  - home
 | 
				
			||||||
---
 | 
					---
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user