@@ -133,22 +133,21 @@ const CarouselView: React.FC<{
133133 columns : 1 | 2 | 3 ;
134134 onSelect : ( p : Project ) => void ;
135135} > = ( { projects, columns, onSelect } ) => {
136- const [ pageIndex , setPageIndex ] = useState ( 0 ) ;
137- const [ direction , setDirection ] = useState ( 0 ) ;
136+ const [ currentIndex , setCurrentIndex ] = useState ( 0 ) ;
137+ const containerRef = useRef < HTMLDivElement > ( null ) ;
138138
139- const maxStart = Math . max ( 0 , projects . length - columns ) ;
139+ const maxIndex = Math . max ( 0 , projects . length - columns ) ;
140140
141- // Reset page when filter or columns change
141+ // Reset when filter or columns change
142142 useEffect ( ( ) => {
143- setPageIndex ( 0 ) ;
143+ setCurrentIndex ( 0 ) ;
144144 } , [ projects . length , columns ] ) ;
145145
146146 const go = useCallback (
147147 ( dir : 1 | - 1 ) => {
148- setDirection ( dir ) ;
149- setPageIndex ( ( prev ) => ( prev + dir + maxStart + 1 ) % ( maxStart + 1 ) ) ;
148+ setCurrentIndex ( ( prev ) => Math . max ( 0 , Math . min ( maxIndex , prev + dir ) ) ) ;
150149 } ,
151- [ maxStart ]
150+ [ maxIndex ]
152151 ) ;
153152
154153 useEffect ( ( ) => {
@@ -162,132 +161,115 @@ const CarouselView: React.FC<{
162161
163162 if ( projects . length === 0 ) return null ;
164163
165- // Get current window of projects (one card shift at a time)
166- const pageProjects = projects . slice ( pageIndex , pageIndex + columns ) ;
167-
168- const variants = {
169- enter : ( d : number ) => ( { x : d > 0 ? 150 : - 150 , opacity : 0 } ) ,
170- center : { x : 0 , opacity : 1 } ,
171- exit : ( d : number ) => ( { x : d > 0 ? - 150 : 150 , opacity : 0 } ) ,
172- } ;
173-
174- const gridClass =
175- columns === 1
176- ? 'grid-cols-1 max-w-2xl mx-auto'
177- : columns === 2
178- ? 'grid-cols-1 md:grid-cols-2'
179- : 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3' ;
164+ // Each card takes 1/columns of the container width, with gap
165+ const gapPx = 24 ;
166+ const cardPercent = 100 / columns ;
167+ const offsetPercent = - ( currentIndex * cardPercent ) ;
180168
181169 return (
182170 < div className = "flex flex-col items-center" >
183- < div className = { ` relative w-full ${ columns === 1 ? 'max-w-2xl' : '' } ` } >
171+ < div className = " relative w-full" >
184172 { /* Nav arrows */ }
185173 < button
186174 type = "button"
187175 onClick = { ( ) => go ( - 1 ) }
188- className = { `absolute ${ columns === 1 ? 'left-0' : '-left-4 sm:-left-12' } top-1/2 -translate-y-1/2 ${ columns === 1 ? '-translate-x-4 sm:-translate-x-12' : '' } z-10 p-2.5 rounded-full bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-500 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white hover:border-surface-400 dark:hover:border-surface-500 shadow-sm transition-all` }
189- aria-label = "Previous page"
176+ disabled = { currentIndex === 0 }
177+ className = { `absolute -left-4 sm:-left-12 top-1/2 -translate-y-1/2 z-10 p-2.5 rounded-full bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-500 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white hover:border-surface-400 dark:hover:border-surface-500 shadow-sm transition-all ${ currentIndex === 0 ? 'opacity-30 cursor-default' : '' } ` }
178+ aria-label = "Previous"
190179 >
191180 < FaChevronLeft className = "text-sm" />
192181 </ button >
193182 < button
194183 type = "button"
195184 onClick = { ( ) => go ( 1 ) }
196- className = { `absolute ${ columns === 1 ? 'right-0' : '-right-4 sm:-right-12' } top-1/2 -translate-y-1/2 ${ columns === 1 ? 'translate-x-4 sm:translate-x-12' : '' } z-10 p-2.5 rounded-full bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-500 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white hover:border-surface-400 dark:hover:border-surface-500 shadow-sm transition-all` }
197- aria-label = "Next page"
185+ disabled = { currentIndex >= maxIndex }
186+ className = { `absolute -right-4 sm:-right-12 top-1/2 -translate-y-1/2 z-10 p-2.5 rounded-full bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-500 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white hover:border-surface-400 dark:hover:border-surface-500 shadow-sm transition-all ${ currentIndex >= maxIndex ? 'opacity-30 cursor-default' : '' } ` }
187+ aria-label = "Next"
198188 >
199189 < FaChevronRight className = "text-sm" />
200190 </ button >
201191
202- { /* Cards Grid */ }
203- < div className = { `overflow-hidden rounded-xl ${ columns === 1 ? 'min-h-[420px]' : 'min-h-[480px]' } ` } >
204- < AnimatePresence mode = "sync" custom = { direction } >
205- < motion . div
206- key = { pageIndex }
207- custom = { direction }
208- variants = { variants }
209- initial = "enter"
210- animate = "center"
211- exit = "exit"
212- transition = { { duration : 0.25 , ease : [ 0.4 , 0 , 0.2 , 1 ] } }
213- className = { `grid ${ gridClass } gap-6` }
214- >
215- { pageProjects . map ( ( project ) => (
216- < article
217- key = { project . id }
218- onClick = { ( ) => onSelect ( project ) }
219- className = "bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl overflow-hidden hover:shadow-lg dark:hover:border-surface-600 transition-shadow cursor-pointer"
220- >
221- < ProjectThumbnail project = { project } className = "h-44" />
222- < div className = "p-5" >
223- < span className = "inline-block text-[10px] font-semibold uppercase tracking-wider text-surface-400 mb-2" >
224- { project . category }
225- </ span >
226- < h3 className = "text-base font-bold text-surface-900 dark:text-white mb-2" >
227- { project . title }
228- </ h3 >
229- < p className = "text-sm text-surface-500 dark:text-surface-400 leading-relaxed mb-3 line-clamp-3" >
230- { project . description }
231- </ p >
232- < div className = "flex flex-wrap gap-1.5 mb-3" >
233- { project . technologies . slice ( 0 , 4 ) . map ( ( tech ) => (
234- < span
235- key = { tech }
236- className = "px-2 py-0.5 bg-surface-50 dark:bg-surface-700 text-surface-600 dark:text-surface-300 rounded text-[11px] font-medium border border-surface-100 dark:border-surface-600"
237- >
238- { tech }
239- </ span >
240- ) ) }
241- { project . technologies . length > 4 && (
242- < span className = "px-2 py-0.5 text-surface-400 text-[11px]" >
243- +{ project . technologies . length - 4 }
244- </ span >
245- ) }
246- </ div >
247- < div className = "flex items-center gap-3 pt-3 border-t border-surface-100 dark:border-surface-700" >
192+ { /* Sliding container */ }
193+ < div ref = { containerRef } className = "overflow-hidden rounded-xl" >
194+ < motion . div
195+ animate = { { x : `calc(${ offsetPercent } % - ${ currentIndex * gapPx } px)` } }
196+ transition = { { type : 'spring' , stiffness : 300 , damping : 30 } }
197+ className = "flex"
198+ style = { { gap : `${ gapPx } px` } }
199+ >
200+ { projects . map ( ( project ) => (
201+ < article
202+ key = { project . id }
203+ onClick = { ( ) => onSelect ( project ) }
204+ className = "bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl overflow-hidden hover:shadow-lg dark:hover:border-surface-600 transition-shadow cursor-pointer flex-shrink-0"
205+ style = { { width : `calc(${ cardPercent } % - ${ gapPx * ( columns - 1 ) / columns } px)` } }
206+ >
207+ < ProjectThumbnail project = { project } className = "h-44" />
208+ < div className = "p-5" >
209+ < span className = "inline-block text-[10px] font-semibold uppercase tracking-wider text-surface-400 mb-2" >
210+ { project . category }
211+ </ span >
212+ < h3 className = "text-base font-bold text-surface-900 dark:text-white mb-2" >
213+ { project . title }
214+ </ h3 >
215+ < p className = "text-sm text-surface-500 dark:text-surface-400 leading-relaxed mb-3 line-clamp-3" >
216+ { project . description }
217+ </ p >
218+ < div className = "flex flex-wrap gap-1.5 mb-3" >
219+ { project . technologies . slice ( 0 , 4 ) . map ( ( tech ) => (
220+ < span
221+ key = { tech }
222+ className = "px-2 py-0.5 bg-surface-50 dark:bg-surface-700 text-surface-600 dark:text-surface-300 rounded text-[11px] font-medium border border-surface-100 dark:border-surface-600"
223+ >
224+ { tech }
225+ </ span >
226+ ) ) }
227+ { project . technologies . length > 4 && (
228+ < span className = "px-2 py-0.5 text-surface-400 text-[11px]" >
229+ +{ project . technologies . length - 4 }
230+ </ span >
231+ ) }
232+ </ div >
233+ < div className = "flex items-center gap-3 pt-3 border-t border-surface-100 dark:border-surface-700" >
234+ < a
235+ href = { project . githubUrl }
236+ target = "_blank"
237+ rel = "noopener noreferrer"
238+ onClick = { ( e ) => e . stopPropagation ( ) }
239+ className = "inline-flex items-center gap-1.5 text-xs font-medium text-surface-500 hover:text-surface-900 dark:hover:text-white transition-colors"
240+ >
241+ < FaGithub className = "text-sm" />
242+ Code
243+ </ a >
244+ { project . liveUrl && (
248245 < a
249- href = { project . githubUrl }
246+ href = { project . liveUrl }
250247 target = "_blank"
251248 rel = "noopener noreferrer"
252249 onClick = { ( e ) => e . stopPropagation ( ) }
253- className = "inline-flex items-center gap-1.5 text-xs font-medium text-surface-500 hover:text-surface-900 dark:hover:text-white transition-colors"
250+ className = "inline-flex items-center gap-1.5 text-xs font-medium text-surface-500 hover:text-primary-600 transition-colors"
254251 >
255- < FaGithub className = "text-sm " />
256- Code
252+ < FaExternalLinkAlt className = "text-[10px] " />
253+ Live Demo
257254 </ a >
258- { project . liveUrl && (
259- < a
260- href = { project . liveUrl }
261- target = "_blank"
262- rel = "noopener noreferrer"
263- onClick = { ( e ) => e . stopPropagation ( ) }
264- className = "inline-flex items-center gap-1.5 text-xs font-medium text-surface-500 hover:text-primary-600 transition-colors"
265- >
266- < FaExternalLinkAlt className = "text-[10px]" />
267- Live Demo
268- </ a >
269- ) }
270- </ div >
255+ ) }
271256 </ div >
272- </ article >
273- ) ) }
274- </ motion . div >
275- </ AnimatePresence >
257+ </ div >
258+ </ article >
259+ ) ) }
260+ </ motion . div >
276261 </ div >
277262 </ div >
278263
279- { /* Dot indicators */ }
264+ { /* Progress indicator */ }
280265 < div className = "flex items-center gap-1.5 mt-6" >
281- { Array . from ( { length : maxStart + 1 } ) . map ( ( _ , i ) => (
266+ { Array . from ( { length : maxIndex + 1 } ) . map ( ( _ , i ) => (
282267 < button
283268 key = { i }
284269 type = "button"
285- onClick = { ( ) => {
286- setDirection ( i > pageIndex ? 1 : - 1 ) ;
287- setPageIndex ( i ) ;
288- } }
270+ onClick = { ( ) => setCurrentIndex ( i ) }
289271 className = { `rounded-full transition-all duration-200 ${
290- i === pageIndex
272+ i === currentIndex
291273 ? 'w-6 h-2 bg-surface-900 dark:bg-white'
292274 : 'w-2 h-2 bg-surface-300 dark:bg-surface-600 hover:bg-surface-400 dark:hover:bg-surface-500'
293275 } `}
@@ -296,7 +278,7 @@ const CarouselView: React.FC<{
296278 ) ) }
297279 </ div >
298280 < p className = "text-xs text-surface-400 mt-3" >
299- { pageIndex + 1 } –{ Math . min ( pageIndex + columns , projects . length ) } of { projects . length }
281+ { currentIndex + 1 } –{ Math . min ( currentIndex + columns , projects . length ) } of { projects . length }
300282 </ p >
301283 </ div >
302284 ) ;
0 commit comments