1- import { useCallback , useEffect , useState } from 'react' ;
1+ import { useCallback , useEffect , useId , useState } from 'react' ;
22import { createPortal } from 'react-dom' ;
33import { ScrollShadow } from '@heroui/react' ;
4- import { AnimatePresence , motion } from 'motion/react' ;
4+ import { AnimatePresence , LayoutGroup , motion } from 'motion/react' ;
55import { TbChevronLeft , TbChevronRight , TbX } from 'react-icons/tb' ;
6- import { CornerBrackets } from './hud/CornerBrackets' ;
76
87type Props = {
98 images : string [ ] ;
109 label : string ;
1110} ;
1211
1312/**
14- * Horizontal row of HUD-framed, duotone screenshot thumbnails. Click opens a
15- * full-colour lightbox with keyboard navigation.
13+ * HUD thumbnail row + lightbox. Opening shares a `layoutId` with the clicked
14+ * thumbnail (app-store style zoom-morph). Inside, all images live on one track
15+ * that translates by index, so prev/next slide left/right.
1616 */
1717export function Gallery ( { images, label } : Props ) {
1818 const [ open , setOpen ] = useState < number | null > ( null ) ;
19+ const uid = useId ( ) ;
20+ const lid = ( i : number ) => `${ uid } -frame-${ i } ` ;
1921
2022 const close = useCallback ( ( ) => setOpen ( null ) , [ ] ) ;
2123 const step = useCallback (
@@ -38,7 +40,7 @@ export function Gallery({ images, label }: Props) {
3840 } , [ open , close , step ] ) ;
3941
4042 return (
41- < div >
43+ < LayoutGroup >
4244 < div className = "mb-2 flex items-center gap-2" >
4345 < span className = "hud-label text-[9px]" > SURVEILLANCE //</ span >
4446 < span className = "hud-label text-[9px] text-[var(--accent)]" >
@@ -62,11 +64,13 @@ export function Gallery({ images, label }: Props) {
6264 } }
6365 aria-label = { `${ label } — frame ${ i + 1 } ` }
6466 >
65- < img
67+ < motion . img
68+ layoutId = { lid ( i ) }
6669 src = { src }
6770 alt = { `${ label } screenshot ${ i + 1 } ` }
6871 loading = "lazy"
6972 className = "size-full object-cover"
73+ style = { { opacity : open === null ? 1 : 0 } }
7074 />
7175 < span className = "absolute left-1.5 top-1.5 bg-[color:var(--background)]/70 px-1 py-0.5 hud-label text-[8px] text-[var(--accent)]" >
7276 { String ( i + 1 ) . padStart ( 2 , '0' ) }
@@ -79,75 +83,87 @@ export function Gallery({ images, label }: Props) {
7983 < AnimatePresence >
8084 { open !== null && (
8185 < motion . div
82- className = "fixed inset-0 z-[70] flex items-center justify-center p-4 sm:p-10"
83- initial = { { opacity : 0 } }
84- animate = { { opacity : 1 } }
85- exit = { { opacity : 0 } }
86- transition = { { duration : 0.18 } }
87- onClick = { close }
88- style = { {
89- background :
90- 'color-mix(in oklab, var(--background) 86%, transparent)' ,
91- backdropFilter : 'blur(8px)' ,
92- } }
93- >
94- < div className = "absolute left-5 top-5 flex items-center gap-3" >
95- < span className = "hud-label text-[var(--accent)]" > { label } </ span >
96- < span className = "hud-label" >
97- { String ( ( open ?? 0 ) + 1 ) . padStart ( 2 , '0' ) } /{ ' ' }
98- { String ( images . length ) . padStart ( 2 , '0' ) }
99- </ span >
100- </ div >
101-
102- < button
86+ key = "lightbox"
87+ className = "fixed inset-0 z-[70] overflow-hidden"
88+ initial = { { opacity : 0 } }
89+ animate = { { opacity : 1 } }
90+ exit = { { opacity : 0 } }
91+ transition = { { duration : 0.25 } }
10392 onClick = { close }
104- className = "pointer-events-auto absolute right-5 top-5 grid size-9 place-items-center border border-[var(--border)] text-[var(--foreground)] transition-colors hover:border-[var(--accent)] hover:text-[var(--accent)]"
105- aria-label = "Close"
106- >
107- < TbX size = { 18 } />
108- </ button >
109-
110- < button
111- onClick = { ( e ) => {
112- e . stopPropagation ( ) ;
113- step ( - 1 ) ;
93+ style = { {
94+ background :
95+ 'color-mix(in oklab, var(--background) 86%, transparent)' ,
96+ backdropFilter : 'blur(8px)' ,
11497 } }
115- className = "pointer-events-auto absolute left-3 top-1/2 grid size-10 -translate-y-1/2 place-items-center border border-[var(--border)] text-[var(--foreground)] transition-colors hover:border-[var(--accent)] hover:text-[var(--accent)]"
116- aria-label = "Previous"
11798 >
118- < TbChevronLeft size = { 20 } />
119- </ button >
120- < button
121- onClick = { ( e ) => {
122- e . stopPropagation ( ) ;
123- step ( 1 ) ;
124- } }
125- className = "pointer-events-auto absolute right-3 top-1/2 grid size-10 -translate-y-1/2 place-items-center border border-[var(--border)] text-[var(--foreground)] transition-colors hover:border-[var(--accent)] hover:text-[var(--accent)]"
126- aria-label = "Next"
127- >
128- < TbChevronRight size = { 20 } />
129- </ button >
99+ { /* sliding track — one image per viewport, translated by index */ }
100+ < motion . div
101+ className = "flex h-full w-full"
102+ initial = { false }
103+ animate = { { x : `${ open * - 100 } vw` } }
104+ transition = { { type : 'spring' , stiffness : 320 , damping : 36 } }
105+ >
106+ { images . map ( ( src , i ) => (
107+ < div
108+ key = { src }
109+ className = "flex h-full w-full shrink-0 items-center justify-center p-4 sm:p-12"
110+ >
111+ < motion . img
112+ layoutId = { lid ( i ) }
113+ src = { src }
114+ alt = { `${ label } screenshot ${ i + 1 } ` }
115+ onClick = { ( e ) => e . stopPropagation ( ) }
116+ className = "max-h-[82vh] max-w-[88vw] cursor-default border border-[var(--border)] object-contain"
117+ transition = { {
118+ layout : { type : 'spring' , stiffness : 240 , damping : 30 } ,
119+ } }
120+ />
121+ </ div >
122+ ) ) }
123+ </ motion . div >
130124
131- < motion . div
132- key = { open }
133- className = "relative max-h-full max-w-4xl"
134- initial = { { scale : 0.97 , opacity : 0 } }
135- animate = { { scale : 1 , opacity : 1 } }
136- transition = { { duration : 0.2 , ease : [ 0.22 , 1 , 0.36 , 1 ] } }
137- onClick = { ( e ) => e . stopPropagation ( ) }
138- >
139- < CornerBrackets size = { 18 } inset = { - 6 } color = "var(--accent)" />
140- < img
141- src = { images [ open ] }
142- alt = { `${ label } screenshot ${ open + 1 } ` }
143- className = "max-h-[80vh] w-auto border border-[var(--border)] object-contain"
144- />
125+ { /* HUD chrome */ }
126+ < div className = "pointer-events-none absolute left-5 top-5 flex items-center gap-3" >
127+ < span className = "hud-label text-[var(--accent)]" > { label } </ span >
128+ < span className = "hud-label" >
129+ { String ( open + 1 ) . padStart ( 2 , '0' ) } /{ ' ' }
130+ { String ( images . length ) . padStart ( 2 , '0' ) }
131+ </ span >
132+ </ div >
133+
134+ < button
135+ onClick = { close }
136+ className = "absolute right-5 top-5 grid size-9 place-items-center border border-[var(--border)] bg-[color:var(--background)]/40 text-[var(--foreground)] transition-colors hover:border-[var(--accent)] hover:text-[var(--accent)]"
137+ aria-label = "Close"
138+ >
139+ < TbX size = { 18 } />
140+ </ button >
141+
142+ < button
143+ onClick = { ( e ) => {
144+ e . stopPropagation ( ) ;
145+ step ( - 1 ) ;
146+ } }
147+ className = "absolute left-3 top-1/2 grid size-10 -translate-y-1/2 place-items-center border border-[var(--border)] bg-[color:var(--background)]/40 text-[var(--foreground)] transition-colors hover:border-[var(--accent)] hover:text-[var(--accent)]"
148+ aria-label = "Previous"
149+ >
150+ < TbChevronLeft size = { 20 } />
151+ </ button >
152+ < button
153+ onClick = { ( e ) => {
154+ e . stopPropagation ( ) ;
155+ step ( 1 ) ;
156+ } }
157+ className = "absolute right-3 top-1/2 grid size-10 -translate-y-1/2 place-items-center border border-[var(--border)] bg-[color:var(--background)]/40 text-[var(--foreground)] transition-colors hover:border-[var(--accent)] hover:text-[var(--accent)]"
158+ aria-label = "Next"
159+ >
160+ < TbChevronRight size = { 20 } />
161+ </ button >
145162 </ motion . div >
146- </ motion . div >
147163 ) }
148164 </ AnimatePresence > ,
149165 document . body
150166 ) }
151- </ div >
167+ </ LayoutGroup >
152168 ) ;
153169}
0 commit comments