From dbba6dcbb12bd914bd3dad66577525750dafb17a Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Thu, 24 Oct 2024 01:40:41 +0300 Subject: [PATCH] perf: carousel + screenshots grid improvements --- frontend/package.json | 2 + frontend/pnpm-lock.yaml | 45 ++++++ frontend/src/common/hooks/useStateRef.ts | 16 ++ .../GameScreenshotsListItem.tsx | 2 +- .../ScreenshotsCarouselModal.tsx | 140 ++++++++++++------ .../pages/Screenshots/ScreenshotsGamePage.tsx | 73 +++++++-- 6 files changed, 221 insertions(+), 57 deletions(-) create mode 100644 frontend/src/common/hooks/useStateRef.ts diff --git a/frontend/package.json b/frontend/package.json index 8e39b8a..41e60bc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,6 +25,7 @@ "react-dom": "^18.3.1", "react-icons": "^5.3.0", "react-router-dom": "^6.27.0", + "react-window": "^1.8.10", "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7" }, @@ -34,6 +35,7 @@ "@types/node": "^22.7.7", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.1", + "@types/react-window": "^1.8.8", "@vitejs/plugin-react": "^4.3.2", "autoprefixer": "^10.4.20", "eslint-plugin-react-hooks": "^5.0.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index d14749c..2825dc8 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: react-router-dom: specifier: ^6.27.0 version: 6.27.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-window: + specifier: ^1.8.10 + version: 1.8.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) tailwind-merge: specifier: ^2.5.4 version: 2.5.4 @@ -75,6 +78,9 @@ importers: '@types/react-dom': specifier: ^18.3.1 version: 18.3.1 + '@types/react-window': + specifier: ^1.8.8 + version: 1.8.8 '@vitejs/plugin-react': specifier: ^4.3.2 version: 4.3.2(vite@5.4.9(@types/node@22.7.7)) @@ -188,6 +194,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.25.9': + resolution: {integrity: sha512-4zpTHZ9Cm6L9L+uIqghQX8ZXg8HKFcjYO3qHoO8zTmRm6HQUJ8SSJ+KRvbMBZn0EGVlT4DRYeQ/6hjlyXBh+Kg==} + engines: {node: '>=6.9.0'} + '@babel/template@7.25.7': resolution: {integrity: sha512-wRwtAgI3bAS+JGU2upWNL9lSlDcRCqD05BZ1n3X2ONLH1WilFP6O1otQjeMK/1g0pvYcXC7b/qVUB1keofjtZA==} engines: {node: '>=6.9.0'} @@ -837,6 +847,9 @@ packages: '@types/react-dom@18.3.1': resolution: {integrity: sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==} + '@types/react-window@1.8.8': + resolution: {integrity: sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==} + '@types/react@18.3.11': resolution: {integrity: sha512-r6QZ069rFTjrEYgFdOck1gK7FLVsgJE7tTz0pQBczlBNUhBNk0MQH4UbnFSwjpQLMkLzgqvBBa+qGpLje16eTQ==} @@ -1634,6 +1647,9 @@ packages: massarg@2.0.1: resolution: {integrity: sha512-AuyHW8IRto9QlYo//7yy1cj8WL97Xi+px0bMa7L3ftuFbMo1BSuXIrfHmNZhzEmfGCl6Nfu1FszVBA2RfrQntA==} + memoize-one@5.2.1: + resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1892,6 +1908,13 @@ packages: '@types/react': optional: true + react-window@1.8.10: + resolution: {integrity: sha512-Y0Cx+dnU6NLa5/EvoHukUD0BklJ8qITCtVEPY1C/nL8wwoZ0b5aEw8Ff1dOVHw7fCzMt55XfJDd8S8W8LCaUCg==} + engines: {node: '>8.0.0'} + peerDependencies: + react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -1907,6 +1930,9 @@ packages: resolution: {integrity: sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==} engines: {node: '>= 0.4'} + regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + regexp.prototype.flags@1.5.3: resolution: {integrity: sha512-vqlC04+RQoFalODCbCumG2xIOvapzVMHwsyIGM/SIE8fRhFFsXeH8/QQ+s0T0kDAhKc4k30s73/0ydkHQz6HlQ==} engines: {node: '>= 0.4'} @@ -2356,6 +2382,10 @@ snapshots: '@babel/core': 7.25.8 '@babel/helper-plugin-utils': 7.25.7 + '@babel/runtime@7.25.9': + dependencies: + regenerator-runtime: 0.14.1 + '@babel/template@7.25.7': dependencies: '@babel/code-frame': 7.25.7 @@ -2873,6 +2903,10 @@ snapshots: dependencies: '@types/react': 18.3.11 + '@types/react-window@1.8.8': + dependencies: + '@types/react': 18.3.11 + '@types/react@18.3.11': dependencies: '@types/prop-types': 15.7.13 @@ -3825,6 +3859,8 @@ snapshots: dependencies: zod: 3.23.8 + memoize-one@5.2.1: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -4054,6 +4090,13 @@ snapshots: optionalDependencies: '@types/react': 18.3.11 + react-window@1.8.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.25.9 + memoize-one: 5.2.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react@18.3.1: dependencies: loose-envify: 1.4.0 @@ -4076,6 +4119,8 @@ snapshots: globalthis: 1.0.4 which-builtin-type: 1.1.4 + regenerator-runtime@0.14.1: {} + regexp.prototype.flags@1.5.3: dependencies: call-bind: 1.0.7 diff --git a/frontend/src/common/hooks/useStateRef.ts b/frontend/src/common/hooks/useStateRef.ts new file mode 100644 index 0000000..9095ee5 --- /dev/null +++ b/frontend/src/common/hooks/useStateRef.ts @@ -0,0 +1,16 @@ +import { useEffect, useRef, useState } from 'react' + +export function useStateRef( + initialValue: T, +): [T, (value: T) => void, React.MutableRefObject] { + const [state, setState] = useState(initialValue) + const ref = useRef(state) + + useEffect(() => { + if (state !== ref.current) { + ref.current = state + } + }, [state]) + + return [state, setState, ref] +} diff --git a/frontend/src/components/GameScreenshotsListItem/GameScreenshotsListItem.tsx b/frontend/src/components/GameScreenshotsListItem/GameScreenshotsListItem.tsx index b3057cf..8e8ba9b 100644 --- a/frontend/src/components/GameScreenshotsListItem/GameScreenshotsListItem.tsx +++ b/frontend/src/components/GameScreenshotsListItem/GameScreenshotsListItem.tsx @@ -37,7 +37,7 @@ export function GameScreenshotsListItem({
- {coll.screenshots.map((entry, i) => ( + {(coll.screenshots ?? []).map((entry, i) => ( & { @@ -19,58 +21,104 @@ export function ScreenshotsCarouselModal( activeIndex?: number | null }, ) { - const { className, activeIndex, ...rest } = props + const { className, screenshots, activeIndex, ...rest } = props + const [idx, setIdx] = useState(activeIndex ?? 0) + useEffect(() => { + setIdx(activeIndex ?? 0) + }, [activeIndex]) + const visible = useMemo(() => { + const thresh = 1 + const min = idx - thresh + const max = idx + thresh + let vis = screenshots.slice(Math.max(0, min), Math.min(idx + thresh + 1, screenshots.length)) + if (min < 0) { + vis = screenshots.slice(min).concat(vis) + } + if (max >= screenshots.length) { + vis = vis.concat(screenshots.slice(0, max - screenshots.length + 1)) + } + + return vis + }, [idx, screenshots]) return (
Screenshots - - - - - +
+ +
+ {visible.map((scr, i) => ( + + ))} +
+ +
) } -function CarouselInner({ - activeIndex, - screenshots, -}: React.ComponentProps) { - const carousel = useCarousel() - const [inView, setInView] = useState(() => carousel.api?.slidesInView() ?? []) - const isInView = useCallback( - (idx: number) => - [-2, -1, 0, 1, 2].map((x) => idx + x).some((x) => activeIndex === x || inView.includes(x)), - [activeIndex, inView], - ) +// +// +// +// +// - useEffect(() => { - if (!carousel.api) { - return - } - const { api } = carousel - const cb = () => { - setInView(api.slidesInView() ?? []) - } - api.on('slidesInView', cb) - return () => { - api.off('slidesInView', cb) - } - }, [carousel]) - - return ( - - {screenshots.map((scr, i) => ( - - - - ))} - - ) -} +// function CarouselInner({ +// activeIndex, +// screenshots, +// }: React.ComponentProps) { +// const carousel = useCarousel() +// const [inView, setInView] = useState(() => carousel.api?.slidesInView() ?? []) +// const isInView = useCallback( +// (idx: number) => +// [-2, -1, 0, 1, 2].map((x) => idx + x).some((x) => activeIndex === x || inView.includes(x)), +// [activeIndex, inView], +// ) +// +// useEffect(() => { +// if (!carousel.api) { +// return +// } +// const { api } = carousel +// const cb = () => { +// setInView(api.slidesInView() ?? []) +// } +// api.on('slidesInView', cb) +// return () => { +// api.off('slidesInView', cb) +// } +// }, [carousel]) +// +// return ( +// +// {screenshots.map((scr, i) => ( +// +// +// +// ))} +// +// ) +// } diff --git a/frontend/src/pages/Screenshots/ScreenshotsGamePage.tsx b/frontend/src/pages/Screenshots/ScreenshotsGamePage.tsx index 09d2c5c..1262677 100644 --- a/frontend/src/pages/Screenshots/ScreenshotsGamePage.tsx +++ b/frontend/src/pages/Screenshots/ScreenshotsGamePage.tsx @@ -4,12 +4,15 @@ import { LoadingContainer } from '@/components/Loader/LoadingContainer' import { Button } from '@/components/ui/button' import { FaAngleLeft } from 'react-icons/fa6' import { ScreenshotImg } from '@/components/ScreenshotImg/ScreenshotImg' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ScreenshotsCarouselModal } from '@/components/ScreenshotsCarouselModal/ScreenshotsCarouselModal' import { Dialog } from '@/components/ui/dialog' import { useGameScreenshots } from '@/common/hooks/useScreenshots' +import { FixedSizeGrid } from 'react-window' +import { useStateRef } from '@/common/hooks/useStateRef' function ScreenshotsGamePage() { + const thumbSize = 256 + 8 const { gameId } = useParams() const { screenshots, isFetching } = useGameScreenshots(gameId!) const [dir] = screenshots.screenshotCollections ?? [{ screenshots: [] }] @@ -25,6 +28,53 @@ function ScreenshotsGamePage() { setModalIndex(null) }, []) + const [gridRef, setGridRef] = useStateRef(null) + const getColCount = useCallback( + () => Math.floor((gridRef?.clientWidth ?? window.innerWidth) / thumbSize), + [gridRef?.clientWidth, thumbSize], + ) + const [colCount, setColCount] = useState(getColCount()) + + useEffect(() => { + const cb = () => { + const w = gridRef?.clientWidth ?? window.innerWidth + const newColCount = Math.floor(w / thumbSize) + console.debug('colCount', newColCount, getColCount()) + if (newColCount !== colCount) { + setColCount(newColCount) + } + } + cb() + window.addEventListener('resize', cb) + return () => { + window.removeEventListener('resize', cb) + } + }, [colCount, getColCount, gridRef, setGridRef, thumbSize]) + + function Cell({ + columnIndex, + rowIndex, + style, + }: { + columnIndex: number + rowIndex: number + style: React.CSSProperties + }) { + const i = rowIndex * colCount + columnIndex + const file = dir.screenshots[i] + if (!file) return null + return ( +
+ +
+ ) + } + return (
@@ -55,15 +105,17 @@ function ScreenshotsGamePage() { onOpenChange={(open) => !open && closeScreenshotsModal()} > -
- {dir.screenshots.map((file, i) => ( - - ))} +
+ + {Cell} +
@@ -72,4 +124,5 @@ function ScreenshotsGamePage() {
) } + export default ScreenshotsGamePage