Files
my-protfolio/src/components/ProjectsSection.tsx

441 lines
15 KiB
TypeScript

'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<HTMLDivElement>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
if (!dragState.current.moved) return;
event.preventDefault();
event.stopPropagation();
dragState.current.moved = false;
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
const drag = scrollbarDragState.current;
if (!drag.active || drag.pointerId !== event.pointerId) return;
scrollFromScrollbarPointer(event.currentTarget, event.clientX, drag.grabOffset);
};
const stopScrollbarDragging = (event: React.PointerEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<section
id="projects"
className={styles.projectsSection}
aria-labelledby="projects-title"
>
<div className="content-container">
<div className={styles.headerRow} data-reveal="soft">
<div className="section-header" style={{ marginBottom: 0 }}>
<span className="section-eyebrow">PROJECTS</span>
<div className="section-title-wrap">
<h2 id="projects-title" className="section-title">
Products I&apos;ve Brought to Life
</h2>
<div className="section-underline" />
</div>
</div>
<div className={styles.arrowButtons}>
<button
type="button"
onClick={() => scrollByCard(-1)}
disabled={!canScrollPrev}
className={`${styles.navButton} ${styles.navButtonPrevious}`}
aria-label="Previous projects"
>
<ArrowLeft size={28} strokeWidth={2.4} />
</button>
<button
type="button"
onClick={() => scrollByCard(1)}
disabled={!canScrollNext}
className={`${styles.navButton} ${styles.navButtonNext}`}
aria-label="Next projects"
>
<ArrowRight size={28} strokeWidth={2.4} />
</button>
</div>
</div>
<div
id="projects-carousel"
ref={viewportRef}
className={`${styles.carouselViewport} ${isDragging ? styles.dragging : ''}`}
onScroll={syncScrollState}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={stopDragging}
onPointerCancel={stopDragging}
onClickCapture={handleClickCapture}
onKeyDown={handleKeyDown}
tabIndex={0}
aria-label="Scrollable project cards"
aria-roledescription="carousel"
>
<div className={styles.carouselTrack} data-reveal-group>
{projects.map((project) => (
<article key={project.id} className={styles.card} aria-label={project.title}>
<div className={styles.mediaContainer} data-reveal="image">
<ProjectThumbnail
image={project.image}
title={project.title}
priority={project.id === 'qlockify'}
className={styles.projectImage}
/>
</div>
<div className={styles.cardBody} data-reveal="soft">
<div className={styles.cardTitleRow}>
<h3 className={styles.cardTitle}>{project.title}</h3>
{project.link && (
<ArrowUpRight
size={19}
strokeWidth={2.5}
className={styles.titleLinkIcon}
aria-hidden="true"
/>
)}
</div>
<div className={styles.cardDate}>{project.date}</div>
<p className={styles.cardDesc}>{project.description}</p>
<div className={styles.tagList}>
{project.tags.map((tag) => (
<span key={tag} className={styles.tagBadge}>
{tag}
</span>
))}
</div>
<div className={styles.cardFooter}>
<a href={project.link || '#'} className={styles.caseStudyLink}>
<span>View case study</span>
<ArrowRight size={18} strokeWidth={2.25} aria-hidden="true" />
</a>
</div>
</div>
</article>
))}
</div>
</div>
<div className={styles.scrollbarWrapper} data-reveal="soft">
<div
className={`${styles.scrollbarTrack} ${isScrollbarDragging ? styles.scrollbarDragging : ''}`}
role="scrollbar"
tabIndex={0}
aria-label="Project carousel position"
aria-controls="projects-carousel"
aria-orientation="horizontal"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(scrollProgress * 100)}
onPointerDown={handleScrollbarPointerDown}
onPointerMove={handleScrollbarPointerMove}
onPointerUp={stopScrollbarDragging}
onPointerCancel={stopScrollbarDragging}
onKeyDown={handleScrollbarKeyDown}
>
<span
className={styles.scrollbarThumb}
style={{
width: `${scrollbarThumbPercent}%`,
transform: `translate3d(${scrollbarThumbPercent < 100
? scrollProgress * ((100 - scrollbarThumbPercent) / scrollbarThumbPercent) * 100
: 0}%, 0, 0)`,
}}
/>
</div>
</div>
</div>
</section>
);
};