Skip to content

Commit beab0da

Browse files
feat: add media carousel
1 parent 14cb329 commit beab0da

6 files changed

Lines changed: 315 additions & 16 deletions

File tree

agents.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ Browser-based retro gaming platform with dual-runtime architecture (Node.js + Cl
1919

2020
**Development**: `pnpm dev` (port 8000). Typically the server should be running, so you don't need to run this. If you are not sure, check whether the 8000 port is in use, which indicates the server is already running.
2121
**Build**: `pnpm build`
22-
**Lint**: `pnpm vp check --fix` (or `pnpm vp staged` for pre-commit)
23-
**Type check**: It's already included in linting, so no separate command needed.
22+
**Lint**: `pnpm vp check --fix`
23+
**Type check**: It's already included in linting, so no separate command needed. _DO NOT_ run `pnpm tsc`.
2424

2525
**E2E Testing** (Playwright):
2626

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
"delegated-events": "1.1.2",
6969
"drizzle-kit": "0.31.10",
7070
"drizzle-orm": "0.45.2",
71+
"embla-carousel-react": "8.6.0",
7172
"es-toolkit": "1.47.0",
7273
"esbuild": "0.28.0",
7374
"execa": "9.6.1",

pnpm-lock.yaml

Lines changed: 28 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import useEmblaCarousel from 'embla-carousel-react'
2+
import { AnimatePresence, motion } from 'motion/react'
3+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
4+
import { RadixThemePortal } from '#@/pages/components/radix-theme-portal.tsx'
5+
import { convertYouTubeIframeURL } from './youtube-embed.tsx'
6+
7+
export interface MediaItem {
8+
type: 'image' | 'video'
9+
src: string
10+
}
11+
12+
interface GameMediaCarouselProps {
13+
items: MediaItem[]
14+
initialIndex: number
15+
originRect: DOMRect | null
16+
open: boolean
17+
onClose: (currentIndex: number) => void
18+
onSlideChange: (index: number) => void
19+
}
20+
21+
export function GameMediaCarousel({
22+
items,
23+
initialIndex,
24+
originRect,
25+
open,
26+
onClose,
27+
onSlideChange,
28+
}: Readonly<GameMediaCarouselProps>) {
29+
const [emblaRef, emblaApi] = useEmblaCarousel({ startIndex: initialIndex })
30+
const [canScrollPrev, setCanScrollPrev] = useState(false)
31+
const [canScrollNext, setCanScrollNext] = useState(false)
32+
const [currentIndex, setCurrentIndex] = useState(initialIndex)
33+
const currentIndexRef = useRef(currentIndex)
34+
currentIndexRef.current = currentIndex
35+
36+
const onEmblaSelect = useCallback(() => {
37+
if (!emblaApi) {
38+
return
39+
}
40+
const idx = emblaApi.selectedScrollSnap()
41+
setCanScrollPrev(emblaApi.canScrollPrev())
42+
setCanScrollNext(emblaApi.canScrollNext())
43+
setCurrentIndex(idx)
44+
onSlideChange(idx)
45+
}, [emblaApi, onSlideChange])
46+
47+
useEffect(() => {
48+
if (!emblaApi) {
49+
return
50+
}
51+
onEmblaSelect()
52+
emblaApi.on('select', onEmblaSelect)
53+
emblaApi.on('reInit', onEmblaSelect)
54+
return () => {
55+
emblaApi.off('select', onEmblaSelect)
56+
emblaApi.off('reInit', onEmblaSelect)
57+
}
58+
}, [emblaApi, onEmblaSelect])
59+
60+
const handleClose = useCallback(() => {
61+
onClose(currentIndexRef.current)
62+
}, [onClose])
63+
64+
useEffect(() => {
65+
if (!open) {
66+
return
67+
}
68+
const abortController = new AbortController()
69+
70+
document.addEventListener(
71+
'keydown',
72+
(event) => {
73+
if (event.key === 'Escape') {
74+
event.preventDefault()
75+
handleClose()
76+
} else if (event.key === 'ArrowLeft') {
77+
event.preventDefault()
78+
emblaApi?.scrollPrev()
79+
} else if (event.key === 'ArrowRight') {
80+
event.preventDefault()
81+
emblaApi?.scrollNext()
82+
}
83+
},
84+
{ signal: abortController.signal },
85+
)
86+
87+
document.body.style.overflow = 'hidden'
88+
89+
return () => {
90+
abortController.abort()
91+
document.body.style.overflow = ''
92+
}
93+
}, [open, handleClose, emblaApi])
94+
95+
const panelAnimProps = useMemo(() => {
96+
if (!originRect) {
97+
return {
98+
animate: { opacity: 1, scale: 1 },
99+
exit: { opacity: 0, scale: 0.95 },
100+
initial: { opacity: 0, scale: 0.95 },
101+
}
102+
}
103+
const left = Math.round(originRect.left)
104+
const top = Math.round(originRect.top)
105+
const width = Math.round(originRect.width)
106+
const height = Math.round(originRect.height)
107+
return {
108+
animate: {
109+
height: Math.round(window.innerHeight * 0.9),
110+
left: Math.round(window.innerWidth * 0.05),
111+
top: Math.round(window.innerHeight * 0.05),
112+
width: Math.round(window.innerWidth * 0.9),
113+
},
114+
exit: { height, left, top, width },
115+
initial: { height, left, top, width },
116+
}
117+
}, [originRect])
118+
119+
return (
120+
<RadixThemePortal>
121+
<AnimatePresence>
122+
{open ? (
123+
<>
124+
{/* Backdrop */}
125+
<motion.div
126+
animate={{ opacity: 1 }}
127+
aria-hidden
128+
className='fixed inset-0 z-50 cursor-default bg-black/80'
129+
exit={{ opacity: 0 }}
130+
initial={{ opacity: 0 }}
131+
onClick={handleClose}
132+
/>
133+
{/* Panel */}
134+
<motion.div
135+
animate={panelAnimProps.animate}
136+
className='fixed z-50'
137+
exit={panelAnimProps.exit}
138+
initial={panelAnimProps.initial}
139+
onClick={(e) => e.stopPropagation()}
140+
>
141+
<div className='flex h-full flex-col overflow-hidden' ref={emblaRef}>
142+
<div className='flex h-full'>
143+
{items.map((item, index) => (
144+
<div className='flex min-h-0 min-w-0 flex-[0_0_100%] items-center justify-center' key={index}>
145+
{item.type === 'video' ? (
146+
<iframe
147+
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'
148+
allowFullScreen
149+
className='h-full w-full border-none bg-black'
150+
referrerPolicy='strict-origin-when-cross-origin'
151+
sandbox='allow-same-origin allow-scripts allow-forms'
152+
src={convertYouTubeIframeURL(item.src)}
153+
title='YouTube video player'
154+
/>
155+
) : (
156+
<img alt='' className='h-full w-full object-contain' src={item.src} />
157+
)}
158+
</div>
159+
))}
160+
</div>
161+
</div>
162+
</motion.div>
163+
{/* Prev button */}
164+
{canScrollPrev ? (
165+
<motion.button
166+
animate={{ opacity: 1 }}
167+
aria-label='Previous'
168+
className='fixed top-1/2 left-[calc(5vw+0.75rem)] z-50 -translate-y-1/2 cursor-pointer rounded-full bg-black/50 p-2 text-white hover:bg-black/70'
169+
exit={{ opacity: 0 }}
170+
initial={{ opacity: 0 }}
171+
onClick={() => emblaApi?.scrollPrev()}
172+
type='button'
173+
>
174+
<span className='icon-[mdi--chevron-left] block size-6' />
175+
</motion.button>
176+
) : null}
177+
{/* Next button */}
178+
{canScrollNext ? (
179+
<motion.button
180+
animate={{ opacity: 1 }}
181+
aria-label='Next'
182+
className='fixed top-1/2 right-[calc(5vw+0.75rem)] z-50 -translate-y-1/2 cursor-pointer rounded-full bg-black/50 p-2 text-white hover:bg-black/70'
183+
exit={{ opacity: 0 }}
184+
initial={{ opacity: 0 }}
185+
onClick={() => emblaApi?.scrollNext()}
186+
type='button'
187+
>
188+
<span className='icon-[mdi--chevron-right] block size-6' />
189+
</motion.button>
190+
) : null}
191+
{/* Close button */}
192+
<motion.button
193+
animate={{ opacity: 1 }}
194+
aria-label='Close'
195+
className='fixed top-4 right-4 z-50 cursor-pointer rounded-full bg-black/50 p-2 text-white hover:bg-black/70'
196+
exit={{ opacity: 0 }}
197+
initial={{ opacity: 0 }}
198+
onClick={handleClose}
199+
type='button'
200+
>
201+
<span className='icon-[mdi--close] block size-6' />
202+
</motion.button>
203+
</>
204+
) : null}
205+
</AnimatePresence>
206+
</RadixThemePortal>
207+
)
208+
}

src/pages/library/platform/rom/components/game-media/game-media.tsx

Lines changed: 75 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { ScrollArea } from '@radix-ui/themes'
22
import { attemptAsync } from 'es-toolkit'
3+
import { useCallback, useRef, useState } from 'react'
34
import useSWRImmutable from 'swr/immutable'
45
import { useRom } from '#@/pages/library/hooks/use-rom.ts'
56
import { getFileUrl } from '#@/pages/library/utils/file.ts'
67
import { imageLoaded } from '#@/utils/client/image.ts'
78
import { getRomGoodcodes, getRomLibretroThumbnail } from '#@/utils/client/library.ts'
89
import { GameMediaDialog } from '../game-media-dialog/game-media-dialog.tsx'
10+
import { GameMediaCarousel, type MediaItem } from './game-media-carousel.tsx'
911
import { YouTubeEmbed } from './youtube-embed.tsx'
1012

1113
export function GameMedia() {
@@ -31,35 +33,95 @@ export function GameMedia() {
3133
return validImages
3234
})
3335

34-
if (!validImages?.length && !video) {
36+
const [carouselOpen, setCarouselOpen] = useState(false)
37+
const [carouselInitialIndex, setCarouselInitialIndex] = useState(0)
38+
const [originRect, setOriginRect] = useState<DOMRect | null>(null)
39+
const thumbRefs = useRef<Map<number, HTMLElement>>(null)
40+
if (!thumbRefs.current) {
41+
thumbRefs.current = new Map()
42+
}
43+
44+
const mediaItems: MediaItem[] = []
45+
if (video) {
46+
mediaItems.push({ src: video, type: 'video' })
47+
}
48+
if (validImages) {
49+
for (const image of validImages) {
50+
mediaItems.push({ src: image, type: 'image' })
51+
}
52+
}
53+
const handleThumbnailClick = useCallback((index: number, event: React.MouseEvent<HTMLElement>) => {
54+
setOriginRect(event.currentTarget.getBoundingClientRect())
55+
setCarouselInitialIndex(index)
56+
setCarouselOpen(true)
57+
}, [])
58+
59+
const handleSlideChange = useCallback((index: number) => {
60+
const el = thumbRefs.current?.get(index)
61+
if (el) {
62+
setOriginRect(el.getBoundingClientRect())
63+
}
64+
}, [])
65+
66+
const handleClose = useCallback((_currentIndex: number) => {
67+
setCarouselOpen(false)
68+
}, [])
69+
70+
if (!mediaItems.length) {
3571
return
3672
}
3773

38-
if (video || title || snap) {
39-
return (
74+
return (
75+
<>
4076
<ScrollArea className='rounded lg:bg-zinc-600/10 lg:p-4' scrollbars='both' size='2'>
4177
<div className='group flex flex-col gap-4 lg:flex-row lg:p-0'>
4278
{video ? (
43-
<div className='w-full lg:w-auto lg:min-w-0 lg:shrink-0'>
44-
<YouTubeEmbed className='h-auto w-full shrink-0 lg:h-48 lg:w-auto' url={video} />
45-
</div>
79+
<button
80+
className='w-full cursor-pointer border-none bg-transparent p-0 lg:w-auto lg:min-w-0 lg:shrink-0'
81+
onClick={(e) => handleThumbnailClick(0, e)}
82+
ref={(el) => {
83+
if (el) {
84+
thumbRefs.current?.set(0, el)
85+
}
86+
}}
87+
type='button'
88+
>
89+
<YouTubeEmbed className='pointer-events-none h-auto w-full shrink-0 lg:h-48 lg:w-auto' url={video} />
90+
</button>
4691
) : null}
47-
{validImages?.map((image) => (
48-
<a className='shrink-0 empty:hidden' href={image} key={image} rel='noreferrer noopener' target='_blank'>
92+
{validImages?.map((image, i) => (
93+
<button
94+
className='shrink-0 cursor-pointer border-none bg-transparent p-0 empty:hidden'
95+
key={image}
96+
onClick={(e) => handleThumbnailClick((video ? 1 : 0) + i, e)}
97+
ref={(el) => {
98+
if (el) {
99+
thumbRefs.current?.set((video ? 1 : 0) + i, el)
100+
}
101+
}}
102+
type='button'
103+
>
49104
<img
50105
alt={getRomGoodcodes(rom).rom}
51-
className='h-auto w-full lg:h-48 lg:w-auto'
52-
key={image}
106+
className='pointer-events-none h-auto w-full lg:h-48 lg:w-auto'
53107
loading='lazy'
54108
src={image}
55109
/>
56-
</a>
110+
</button>
57111
))}
58112
<div className='flex items-center justify-end p-1.5 lg:w-auto'>
59113
<GameMediaDialog />
60114
</div>
61115
</div>
62116
</ScrollArea>
63-
)
64-
}
117+
<GameMediaCarousel
118+
initialIndex={carouselInitialIndex}
119+
items={mediaItems}
120+
onClose={handleClose}
121+
onSlideChange={handleSlideChange}
122+
open={carouselOpen}
123+
originRect={originRect}
124+
/>
125+
</>
126+
)
65127
}

0 commit comments

Comments
 (0)