'use client'; import React, { useEffect, useRef, useState } from 'react'; import { ArrowLeft, ArrowRight, ArrowUpRight } from 'lucide-react'; import { projects } from '@/data/projects'; import { ProjectThumbnail } from './ProjectThumbnail'; import styles from './ProjectsSection.module.css'; const MIN_SCROLLBAR_THUMB_PERCENT = 12.5; export const ProjectsSection: React.FC = () => { const viewportRef = useRef(null); const dragState = useRef({ active: false, moved: false, startX: 0, startY: 0, startScrollLeft: 0, lastX: 0, lastTime: 0, velocity: 0, pointerId: -1, directionLocked: null as 'horizontal' | 'vertical' | null, }); const scrollbarDragState = useRef({ active: false, pointerId: -1, grabOffset: 0, }); const [isDragging, setIsDragging] = useState(false); const [isScrollbarDragging, setIsScrollbarDragging] = useState(false); const [scrollProgress, setScrollProgress] = useState(0); const [scrollbarThumbPercent, setScrollbarThumbPercent] = useState( MIN_SCROLLBAR_THUMB_PERCENT, ); const [canScrollPrev, setCanScrollPrev] = useState(false); const [canScrollNext, setCanScrollNext] = useState(true); const syncScrollState = () => { const viewport = viewportRef.current; if (!viewport) return; const maxScroll = Math.max(0, viewport.scrollWidth - viewport.clientWidth); setScrollProgress( maxScroll > 0 ? Math.max(0, Math.min(viewport.scrollLeft / maxScroll, 1)) : 0, ); setScrollbarThumbPercent( Math.max( MIN_SCROLLBAR_THUMB_PERCENT, Math.min((viewport.clientWidth / viewport.scrollWidth) * 100, 100), ), ); setCanScrollPrev(viewport.scrollLeft > 1); setCanScrollNext(viewport.scrollLeft < maxScroll - 1); }; useEffect(() => { syncScrollState(); const viewport = viewportRef.current; if (!viewport) return; const resizeObserver = new ResizeObserver(syncScrollState); resizeObserver.observe(viewport); return () => resizeObserver.disconnect(); }, []); const scrollByCard = (direction: -1 | 1) => { const viewport = viewportRef.current; const track = viewport?.firstElementChild as HTMLElement | null; const card = track?.firstElementChild as HTMLElement | null; if (!viewport || !track || !card) return; const gap = Number.parseFloat(window.getComputedStyle(track).columnGap) || 0; viewport.scrollBy({ left: direction * (card.getBoundingClientRect().width + gap), behavior: 'smooth', }); }; const handlePointerDown = (event: React.PointerEvent) => { if (event.button !== 0 && event.pointerType === 'mouse') return; const viewport = viewportRef.current; if (!viewport) return; dragState.current = { active: true, moved: false, startX: event.clientX, startY: event.clientY, startScrollLeft: viewport.scrollLeft, lastX: event.clientX, lastTime: performance.now(), velocity: 0, pointerId: event.pointerId, directionLocked: null, }; }; const handlePointerMove = (event: React.PointerEvent) => { const viewport = viewportRef.current; if (!viewport || !dragState.current.active) return; const deltaX = event.clientX - dragState.current.startX; const deltaY = event.clientY - dragState.current.startY; // Detect user intent: horizontal carousel swipe vs vertical page scroll if (!dragState.current.directionLocked) { if (Math.abs(deltaX) < 6 && Math.abs(deltaY) < 6) return; if (Math.abs(deltaY) > Math.abs(deltaX)) { // User intends to scroll page vertically, release touch dragState.current.directionLocked = 'vertical'; dragState.current.active = false; return; } // User intends horizontal carousel drag dragState.current.directionLocked = 'horizontal'; try { viewport.setPointerCapture(event.pointerId); } catch { // Ignore if pointer capture unavailable } setIsDragging(true); } if (dragState.current.directionLocked === 'horizontal') { const now = performance.now(); const dt = Math.max(now - dragState.current.lastTime, 1); const dx = event.clientX - dragState.current.lastX; dragState.current.velocity = dx / dt; dragState.current.lastX = event.clientX; dragState.current.lastTime = now; if (Math.abs(deltaX) > 4) dragState.current.moved = true; viewport.scrollLeft = dragState.current.startScrollLeft - deltaX; } }; const stopDragging = (event: React.PointerEvent) => { const viewport = viewportRef.current; if (!viewport || !dragState.current.active) { setIsDragging(false); return; } const wasHorizontal = dragState.current.directionLocked === 'horizontal'; const moved = dragState.current.moved; const velocity = dragState.current.velocity; const clientX = event.clientX ?? dragState.current.lastX; const totalDeltaX = clientX - dragState.current.startX; const pointerId = dragState.current.pointerId; dragState.current.active = false; if (viewport.hasPointerCapture(pointerId)) { try { viewport.releasePointerCapture(pointerId); } catch { // Ignore } } setIsDragging(false); if (!wasHorizontal || !moved) return; // Calculate snap target with velocity fling const track = viewport.firstElementChild as HTMLElement | null; const card = track?.firstElementChild as HTMLElement | null; if (!track || !card) return; const cardWidth = card.getBoundingClientRect().width; const gap = Number.parseFloat(window.getComputedStyle(track).columnGap) || 0; const step = cardWidth + gap; if (step <= 0) return; const currentScroll = viewport.scrollLeft; let targetIndex: number; if (Math.abs(velocity) > 0.2 || Math.abs(totalDeltaX) > cardWidth * 0.18) { if (totalDeltaX < 0 || velocity < -0.2) { targetIndex = Math.ceil(currentScroll / step); } else { targetIndex = Math.floor(currentScroll / step); } } else { targetIndex = Math.round(currentScroll / step); } const maxIndex = projects.length - 1; targetIndex = Math.max(0, Math.min(targetIndex, maxIndex)); viewport.scrollTo({ left: targetIndex * step, behavior: 'smooth', }); }; const handleClickCapture = (event: React.MouseEvent) => { if (!dragState.current.moved) return; event.preventDefault(); event.stopPropagation(); dragState.current.moved = false; }; const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'ArrowLeft') { event.preventDefault(); scrollByCard(-1); } else if (event.key === 'ArrowRight') { event.preventDefault(); scrollByCard(1); } }; const scrollFromScrollbarPointer = ( track: HTMLDivElement, clientX: number, grabOffset: number, ) => { const viewport = viewportRef.current; if (!viewport) return; const trackRect = track.getBoundingClientRect(); const thumb = track.firstElementChild as HTMLElement | null; const thumbWidth = thumb?.getBoundingClientRect().width ?? 0; const availableTrack = trackRect.width - thumbWidth; const maxScroll = Math.max(0, viewport.scrollWidth - viewport.clientWidth); if (availableTrack <= 0 || maxScroll <= 0) return; const thumbLeft = Math.max( 0, Math.min(clientX - trackRect.left - grabOffset, availableTrack), ); const nextProgress = thumbLeft / availableTrack; setScrollProgress(nextProgress); viewport.scrollLeft = nextProgress * maxScroll; }; const handleScrollbarPointerDown = (event: React.PointerEvent) => { if (event.button !== 0 && event.pointerType === 'mouse') return; const track = event.currentTarget; const thumb = track.firstElementChild as HTMLElement | null; if (!thumb) return; const thumbRect = thumb.getBoundingClientRect(); const clickedThumb = thumb.contains(event.target as Node); const grabOffset = clickedThumb ? event.clientX - thumbRect.left : thumbRect.width / 2; scrollbarDragState.current = { active: true, pointerId: event.pointerId, grabOffset, }; track.setPointerCapture(event.pointerId); setIsScrollbarDragging(true); if (!clickedThumb) { scrollFromScrollbarPointer(track, event.clientX, grabOffset); } event.preventDefault(); }; const handleScrollbarPointerMove = (event: React.PointerEvent) => { const drag = scrollbarDragState.current; if (!drag.active || drag.pointerId !== event.pointerId) return; scrollFromScrollbarPointer(event.currentTarget, event.clientX, drag.grabOffset); }; const stopScrollbarDragging = (event: React.PointerEvent) => { const drag = scrollbarDragState.current; if (!drag.active || drag.pointerId !== event.pointerId) return; drag.active = false; if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } setIsScrollbarDragging(false); }; const handleScrollbarKeyDown = (event: React.KeyboardEvent) => { const viewport = viewportRef.current; if (!viewport) return; if (event.key === 'ArrowLeft') { event.preventDefault(); scrollByCard(-1); } else if (event.key === 'ArrowRight') { event.preventDefault(); scrollByCard(1); } else if (event.key === 'Home' || event.key === 'End') { event.preventDefault(); viewport.scrollTo({ left: event.key === 'Home' ? 0 : viewport.scrollWidth - viewport.clientWidth, behavior: 'smooth', }); } }; return (
PROJECTS

Products I've Brought to Life

); };