mirror of
https://github.com/chenasraf/stimvisor.git
synced 2026-05-18 01:39:07 +00:00
perf: carousel + screenshots grid improvements
This commit is contained in:
@@ -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",
|
||||
|
||||
45
frontend/pnpm-lock.yaml
generated
45
frontend/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
16
frontend/src/common/hooks/useStateRef.ts
Normal file
16
frontend/src/common/hooks/useStateRef.ts
Normal 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]
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
// )
|
||||
// }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user