perf: carousel + screenshots grid improvements

This commit is contained in:
2024-10-24 01:40:41 +03:00
parent e1feb33f0a
commit dbba6dcbb1
6 changed files with 221 additions and 57 deletions

View File

@@ -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",

View File

@@ -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

View File

@@ -0,0 +1,16 @@
import { useEffect, useRef, useState } from 'react'
export function useStateRef<T>(
initialValue: T,
): [T, (value: T) => void, React.MutableRefObject<T>] {
const [state, setState] = useState(initialValue)
const ref = useRef(state)
useEffect(() => {
if (state !== ref.current) {
ref.current = state
}
}, [state])
return [state, setState, ref]
}

View File

@@ -37,7 +37,7 @@ export function GameScreenshotsListItem({
</div>
</div>
<div className="flex items-start gap-4 flex-nowrap overflow-x-hidden max-w-full">
{coll.screenshots.map((entry, i) => (
{(coll.screenshots ?? []).map((entry, i) => (
<ScreenshotImg
className="max-w-64 rounded-md"
screenshot={entry}

View File

@@ -11,7 +11,9 @@ import {
useCarousel,
} from '../ui/carousel'
import { ScreenshotImg } from '../ScreenshotImg/ScreenshotImg'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '../ui/button'
import { FaAngleLeft, FaAngleRight } from 'react-icons/fa6'
export function ScreenshotsCarouselModal(
props: HtmlProps<'div'> & {
@@ -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 (
<div className={cn('', className)} {...rest}>
<DialogContent className="max-w-[calc(100%_-_128px)] max-h-[calc(100%_-_64px)]">
<DialogTitle>Screenshots</DialogTitle>
<Carousel opts={{ startIndex: activeIndex ?? undefined, loop: true }}>
<CarouselInner {...props} />
<CarouselPrevious />
<CarouselNext />
</Carousel>
<div className="flex gap-4 items-center w-full">
<Button
className="flex-shrink-0"
variant="outline"
size="icon"
onClick={() => setIdx((i) => (i === 0 ? screenshots.length - 1 : --i))}
>
<FaAngleLeft />
</Button>
<div className="flex-grow flex place-content-center">
{visible.map((scr, i) => (
<ScreenshotImg
key={scr.path}
screenshot={scr}
className={cn('max-h-[calc(100vh_-_160px)] object-cover', i !== 1 && 'hidden')}
/>
))}
</div>
<Button
className="flex-shrink-0"
variant="outline"
size="icon"
onClick={() => setIdx((i) => (i === screenshots.length ? 0 : ++i))}
>
<FaAngleRight />
</Button>
</div>
</DialogContent>
</div>
)
}
function CarouselInner({
activeIndex,
screenshots,
}: React.ComponentProps<typeof ScreenshotsCarouselModal>) {
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],
)
// <Carousel opts={{ startIndex: activeIndex ?? undefined, loop: false }}>
// <CarouselInner {...props} />
// <CarouselPrevious />
// <CarouselNext />
// </Carousel>
useEffect(() => {
if (!carousel.api) {
return
}
const { api } = carousel
const cb = () => {
setInView(api.slidesInView() ?? [])
}
api.on('slidesInView', cb)
return () => {
api.off('slidesInView', cb)
}
}, [carousel])
return (
<CarouselContent className="max-h-full">
{screenshots.map((scr, i) => (
<CarouselItem key={scr.path} className="flex items-center justify-center">
<ScreenshotImg
screenshot={scr}
load={isInView(i)}
className="max-h-[calc(100vh_-_160px)] object-cover mx-auto"
/>
</CarouselItem>
))}
</CarouselContent>
)
}
// function CarouselInner({
// activeIndex,
// screenshots,
// }: React.ComponentProps<typeof ScreenshotsCarouselModal>) {
// 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 (
// <CarouselContent className="max-h-full">
// {screenshots.map((scr, i) => (
// <CarouselItem key={scr.path} className="flex items-center justify-center">
// <ScreenshotImg
// screenshot={scr}
// load={isInView(i)}
// className="max-h-[calc(100vh_-_160px)] object-cover"
// />
// </CarouselItem>
// ))}
// </CarouselContent>
// )
// }

View File

@@ -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<HTMLDivElement | null>(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 (
<div style={{ width: thumbSize, ...style }} className="p-1">
<ScreenshotImg
className="rounded-md"
screenshot={file}
key={file.path}
onClick={openScreenshotsModal(i)}
/>
</div>
)
}
return (
<div className="relative">
<div className="sticky top-0 p-4 bg-background flex flex-col gap-2 z-10">
@@ -55,15 +105,17 @@ function ScreenshotsGamePage() {
onOpenChange={(open) => !open && closeScreenshotsModal()}
>
<LoadingContainer loading={isFetching}>
<div className="flex items-start gap-4 flex-wrap max-w-full">
{dir.screenshots.map((file, i) => (
<ScreenshotImg
className="max-w-64 rounded-md"
screenshot={file}
key={file.path}
onClick={openScreenshotsModal(i)}
/>
))}
<div ref={setGridRef}>
<FixedSizeGrid
columnCount={colCount}
columnWidth={thumbSize}
height={window.innerHeight - 200}
rowCount={Math.ceil((dir.screenshots?.length ?? 0) / colCount)}
rowHeight={168}
width={colCount * (thumbSize + 4)}
>
{Cell}
</FixedSizeGrid>
</div>
</LoadingContainer>
<ScreenshotsCarouselModal screenshots={modalScreenshots} activeIndex={modalIndex} />
@@ -72,4 +124,5 @@ function ScreenshotsGamePage() {
</div>
)
}
export default ScreenshotsGamePage