Skip to content

Commit ed728da

Browse files
authored
Refactor/virtual list (#29)
* feat(dependencies): add @tanstack/react-virtual package * Introduced @tanstack/react-virtual version 3.13.22 to enhance virtual scrolling capabilities. * Updated related dependencies in composer.json and package.json for compatibility. * Ensured all lock files are in sync with the latest changes. * refactor(App): optimize user filtering with useMemo * refactor(imageCache): implement LRU caching for images * Replace simple cache with LRUImageCache class * Add methods for managing cache entries (get, set, delete, clear) * Update SecureImage component to utilize new caching logic * Abort fetch requests on component unmount to prevent memory leaks * refactor(UserGrid): implement virtual scrolling for user cards * Replace infinite scroll with virtual scrolling for improved performance * Add responsive column detection based on container width * Update UserGrid props to accommodate new structure * Refactor UserCard animations to prevent re-animation on scroll * refactor(UserGrid): improve virtual row animation and stagger * Replace scrollContainerRef with callback ref for scroll container * Implement staggered animation for user cards using motion variants * Prevent re-animation of already seen cards on scroll-back * Update eslint config to disable false positive for react-virtual * chore(eslint): disable false positive for react-hooks with react-virtual * Revert "refactor(UserGrid): improve virtual row animation and stagger" This reverts commit 246aa9a. * Revert "refactor(UserGrid): implement virtual scrolling for user cards" This reverts commit 254da19. * refactor(UserGrid): implement virtualized rendering for user cards * Replace infinite scroll with virtualizer for improved performance * Dynamically calculate grid columns based on screen size * Update animation variants for smoother transitions * Remove observer and loading spinner logic * refactor(css): simplify view transition animations and remove media query * refactor(PhotoLightbox): render with portal to document body * refactor: improve image loading and animation * Add async decoding and lazy loading to SecureImage for better performance * Change UserGrid animation trigger from whileInView to animate for consistency * refactor: ensure full-height card layout * Add h-full class to UserCard and UserGrid item containers * Improves card alignment and consistent sizing in grid * docs(README): update features to reflect virtual list usage
1 parent 6d45173 commit ed728da

15 files changed

Lines changed: 349 additions & 272 deletions

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,11 @@
5050
- 🛡️ **Rôles dynamiques** — Droits basés sur les groupes AD (ROLE_USER / ROLE_ADMIN) avec Voter Symfony
5151
- 🔑 **API sécurisée JWT** — Authentification stateless via JSON Web Tokens
5252
- 🔍 **Recherche instantanée** — Filtrage avec debounce (300 ms) par nom, prénom ou service, avec skeleton de chargement
53-
- ♾️ **Infinite scroll** — Chargement progressif par lots de 24 fiches via IntersectionObserver
53+
- ♾️ **Virtual list** — Chargement progressif des utilisateurs avec react-virtual
5454
- 🌙 **Dark mode** — Thème sombre avec animation circulaire (View Transition API) depuis le point de clic
5555
- 🎬 **Animations fluides** — Transitions de page, modales animées, micro-interactions (Motion/Framer Motion)
5656
- 💬 **Notifications toast** — Retours visuels avec barre de progression et pause au survol
57-
-**Cache intelligent** — Cache des données utilisateur en sessionStorage (TTL 5 min) + cache mémoire des images (Object URL)
57+
-**Cache intelligent** — Cache des données utilisateur en sessionStorage (TTL 5 min) + cache mémoire des images
5858
- 📜 **Mentions légales & RGPD** — Modale intégrée accessible depuis la page de connexion et le footer
5959
- 📱 **Responsive** — Interface adaptée mobile, tablette et desktop
6060
- 🎨 **Personnalisable** — Nom de l'organisation et titre de l'application configurables via variables d'environnement

client/bun.lock

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/eslint.config.js

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import js from '@eslint/js'
2-
import globals from 'globals'
3-
import reactHooks from 'eslint-plugin-react-hooks'
4-
import reactRefresh from 'eslint-plugin-react-refresh'
5-
import tseslint from 'typescript-eslint'
6-
import { defineConfig, globalIgnores } from 'eslint/config'
7-
import eslintConfigPrettier from 'eslint-config-prettier'
1+
import js from '@eslint/js';
2+
import globals from 'globals';
3+
import reactHooks from 'eslint-plugin-react-hooks';
4+
import reactRefresh from 'eslint-plugin-react-refresh';
5+
import tseslint from 'typescript-eslint';
6+
import { defineConfig, globalIgnores } from 'eslint/config';
7+
import eslintConfigPrettier from 'eslint-config-prettier';
88

99
export default defineConfig([
1010
globalIgnores(['dist']),
@@ -20,6 +20,10 @@ export default defineConfig([
2020
ecmaVersion: 2020,
2121
globals: globals.browser,
2222
},
23+
rules: {
24+
// Faux positif : @tanstack/react-virtual est compatible React mais pas reconnu par le plugin
25+
'react-hooks/incompatible-library': 'off',
26+
},
2327
},
24-
eslintConfigPrettier
25-
])
28+
eslintConfigPrettier,
29+
]);

client/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
},
2929
"dependencies": {
3030
"@tailwindcss/vite": "^4.2.1",
31+
"@tanstack/react-virtual": "^3.13.22",
3132
"flowbite": "^4.0.1",
3233
"lucide-react": "^0.577.0",
3334
"motion": "^12.35.2",

client/src/App.tsx

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useEffect, useCallback } from 'react';
1+
import { useState, useEffect, useCallback, useMemo } from 'react';
22
import { type User } from './components/UserCard';
33
import LoginPage from './components/LoginPage';
44
import AppNav from './components/AppNav';
@@ -11,7 +11,6 @@ import { useTheme } from './hooks/useTheme';
1111
import { useToast } from './hooks/useToast';
1212
import { useAuth } from './hooks/useAuth';
1313
import { useUsers } from './hooks/useUsers';
14-
import { useInfiniteScroll } from './hooks/useInfiniteScroll';
1514
import { AnimatePresence, motion, type Transition } from 'motion/react';
1615

1716
const pageVariants = {
@@ -50,15 +49,16 @@ export default function App() {
5049

5150
const isSearching = searchTerm !== debouncedSearch;
5251

53-
const filteredUsers = users.filter((user) =>
54-
`${user.firstName} ${user.lastName} ${user.department}`
55-
.toLowerCase()
56-
.includes(debouncedSearch.toLowerCase())
52+
const filteredUsers = useMemo(
53+
() =>
54+
users.filter((user) =>
55+
`${user.firstName} ${user.lastName} ${user.department}`
56+
.toLowerCase()
57+
.includes(debouncedSearch.toLowerCase())
58+
),
59+
[users, debouncedSearch]
5760
);
5861

59-
// Infinite scroll
60-
const { visibleUsers, hasMore, observerTarget } = useInfiniteScroll(filteredUsers, searchTerm);
61-
6262
// État des modales
6363
const [isUploadOpen, setIsUploadOpen] = useState(false);
6464
const [selectedUser, setSelectedUser] = useState<User | null>(null);
@@ -147,13 +147,11 @@ export default function App() {
147147
<main className="mx-auto w-full max-w-6xl p-4 sm:p-6 lg:p-8">
148148
<UserGrid
149149
allUsers={users}
150-
visibleUsers={visibleUsers}
150+
visibleUsers={filteredUsers}
151151
filteredCount={filteredUsers.length}
152152
isAdmin={isAdmin}
153153
loggedUsername={username}
154-
hasMore={hasMore}
155154
isSearching={isSearching}
156-
observerTarget={observerTarget}
157155
onEditPhoto={openUpload}
158156
onDeletePhoto={openDelete}
159157
/>

client/src/components/PhotoLightbox.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useEffect } from 'react';
2+
import { createPortal } from 'react-dom';
23
import { motion } from 'motion/react';
34
import { X } from 'lucide-react';
45
import { SecureImage } from './SecureImage';
@@ -29,7 +30,7 @@ export function PhotoLightbox({ src, alt, onClose }: PhotoLightboxProps) {
2930
return () => window.removeEventListener('keydown', handleKey);
3031
}, [onClose]);
3132

32-
return (
33+
return createPortal(
3334
<motion.div
3435
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"
3536
variants={overlayVariants}
@@ -64,6 +65,7 @@ export function PhotoLightbox({ src, alt, onClose }: PhotoLightboxProps) {
6465
className="max-h-[90vh] max-w-[90vw] rounded-xl object-contain shadow-2xl"
6566
/>
6667
</motion.div>
67-
</motion.div>
68+
</motion.div>,
69+
document.body
6870
);
6971
}

client/src/components/SecureImage.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,25 +17,31 @@ export function SecureImage({ src, alt, className }: SecureImageProps) {
1717
useEffect(() => {
1818
if (!src) return;
1919

20-
// Déjà en cache → pas besoin de fetcher
21-
if (imageCache.has(src)) return;
20+
// Déjà en cache → on marque comme récemment utilisé et on se met à jour si besoin
21+
const cached = imageCache.get(src);
22+
if (cached !== undefined) {
23+
// eslint-disable-next-line react-hooks/set-state-in-effect
24+
setImageSrc(cached);
25+
return;
26+
}
2227

2328
let isMounted = true;
29+
const controller = new AbortController();
2430

2531
const fetchImage = async () => {
2632
try {
2733
const response = await fetch(src, {
2834
headers: {
2935
Authorization: `Bearer ${token}`,
3036
},
37+
signal: controller.signal,
3138
});
3239

3340
if (!response.ok) {
3441
if (response.status === 401) {
3542
handleLogout();
3643
toastError('Session expirée, veuillez vous reconnecter.');
3744
}
38-
3945
return;
4046
}
4147

@@ -47,6 +53,7 @@ export function SecureImage({ src, alt, className }: SecureImageProps) {
4753
setImageSrc(objectUrl);
4854
}
4955
} catch (error) {
56+
if (error instanceof Error && error.name === 'AbortError') return;
5057
console.error("Impossible de charger l'image protégée", error);
5158
}
5259
};
@@ -55,13 +62,13 @@ export function SecureImage({ src, alt, className }: SecureImageProps) {
5562

5663
return () => {
5764
isMounted = false;
58-
// On ne révoque PAS l'objectUrl ici : il reste dans le cache
65+
controller.abort(); // Annule le fetch si le composant démonte avant la fin
5966
};
60-
}, [src, token]);
67+
}, [src, token, handleLogout, toastError]);
6168

6269
if (!imageSrc) {
6370
return <div className={`animate-pulse bg-gray-200 dark:bg-gray-700 ${className}`}></div>;
6471
}
6572

66-
return <img src={imageSrc} alt={alt} className={className} />;
73+
return <img src={imageSrc} alt={alt} className={className} decoding="async" loading="lazy" />;
6774
}

client/src/components/UserCard.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export default memo(function UserCard({
4545
<motion.div
4646
variants={itemVariants}
4747
transition={itemTransition}
48-
className="w-full rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-800"
48+
className="h-full w-full rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-800"
4949
>
5050
<div className="flex h-full flex-col items-center px-4 pt-6 pb-6">
5151
{/* AVATAR CONTAINER */}

0 commit comments

Comments
 (0)