504 lines
17 KiB
TypeScript
504 lines
17 KiB
TypeScript
'use client';
|
|
|
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { ArrowLeft, ArrowRight, ArrowUpRight } from 'lucide-react';
|
|
import { getProjects } from '@/data/projects';
|
|
import { messagesByLocale, type Locale } from '@/i18n/content';
|
|
import { ProjectThumbnail } from './ProjectThumbnail';
|
|
import styles from './ProjectsSection.module.css';
|
|
|
|
const MIN_SCROLLBAR_THUMB_PERCENT = 12.5;
|
|
|
|
const getLogicalScrollLeft = (viewport: HTMLDivElement, isRtl: boolean) =>
|
|
isRtl ? -viewport.scrollLeft : viewport.scrollLeft;
|
|
|
|
const setLogicalScrollLeft = (
|
|
viewport: HTMLDivElement,
|
|
value: number,
|
|
isRtl: boolean,
|
|
behavior: ScrollBehavior = 'auto',
|
|
) => {
|
|
viewport.scrollTo({ left: isRtl ? -value : value, behavior });
|
|
};
|
|
|
|
export const ProjectsSection = ({ locale }: { locale: Locale }) => {
|
|
const isRtl = locale === 'fa';
|
|
const projects = getProjects(locale);
|
|
const copy = messagesByLocale[locale].projects;
|
|
const viewportRef = useRef<HTMLDivElement>(null);
|
|
const dragState = useRef({
|
|
active: false,
|
|
moved: false,
|
|
startX: 0,
|
|
startY: 0,
|
|
startLogicalScrollLeft: 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 = useCallback(() => {
|
|
const viewport = viewportRef.current;
|
|
if (!viewport) return;
|
|
|
|
const maxScroll = Math.max(0, viewport.scrollWidth - viewport.clientWidth);
|
|
const logicalScroll = getLogicalScrollLeft(viewport, isRtl);
|
|
|
|
setScrollProgress(
|
|
maxScroll > 0 ? Math.max(0, Math.min(logicalScroll / maxScroll, 1)) : 0,
|
|
);
|
|
setScrollbarThumbPercent(
|
|
Math.max(
|
|
MIN_SCROLLBAR_THUMB_PERCENT,
|
|
Math.min((viewport.clientWidth / viewport.scrollWidth) * 100, 100),
|
|
),
|
|
);
|
|
setCanScrollPrev(logicalScroll > 1);
|
|
setCanScrollNext(logicalScroll < maxScroll - 1);
|
|
}, [isRtl]);
|
|
|
|
useEffect(() => {
|
|
syncScrollState();
|
|
const viewport = viewportRef.current;
|
|
if (!viewport) return;
|
|
|
|
const resizeObserver = new ResizeObserver(syncScrollState);
|
|
resizeObserver.observe(viewport);
|
|
return () => resizeObserver.disconnect();
|
|
}, [syncScrollState]);
|
|
|
|
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;
|
|
const maxScroll = Math.max(0, viewport.scrollWidth - viewport.clientWidth);
|
|
const nextScroll = Math.max(
|
|
0,
|
|
Math.min(
|
|
getLogicalScrollLeft(viewport, isRtl) +
|
|
direction * (card.getBoundingClientRect().width + gap),
|
|
maxScroll,
|
|
),
|
|
);
|
|
setLogicalScrollLeft(viewport, nextScroll, isRtl, '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,
|
|
startLogicalScrollLeft: getLogicalScrollLeft(viewport, isRtl),
|
|
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;
|
|
setLogicalScrollLeft(
|
|
viewport,
|
|
dragState.current.startLogicalScrollLeft - deltaX,
|
|
isRtl,
|
|
);
|
|
}
|
|
};
|
|
|
|
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 = getLogicalScrollLeft(viewport, isRtl);
|
|
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));
|
|
|
|
setLogicalScrollLeft(viewport, targetIndex * step, isRtl, '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(isRtl ? 1 : -1);
|
|
} else if (event.key === 'ArrowRight') {
|
|
event.preventDefault();
|
|
scrollByCard(isRtl ? -1 : 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 pointerFromOrigin = isRtl
|
|
? trackRect.right - clientX
|
|
: clientX - trackRect.left;
|
|
const thumbOffset = Math.max(
|
|
0,
|
|
Math.min(pointerFromOrigin - grabOffset, availableTrack),
|
|
);
|
|
const nextProgress = thumbOffset / availableTrack;
|
|
setScrollProgress(nextProgress);
|
|
setLogicalScrollLeft(viewport, nextProgress * maxScroll, isRtl);
|
|
};
|
|
|
|
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
|
|
? isRtl
|
|
? thumbRect.right - event.clientX
|
|
: 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(isRtl ? 1 : -1);
|
|
} else if (event.key === 'ArrowRight') {
|
|
event.preventDefault();
|
|
scrollByCard(isRtl ? -1 : 1);
|
|
} else if (event.key === 'Home' || event.key === 'End') {
|
|
event.preventDefault();
|
|
setLogicalScrollLeft(
|
|
viewport,
|
|
event.key === 'Home' ? 0 : viewport.scrollWidth - viewport.clientWidth,
|
|
isRtl,
|
|
'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">{copy.eyebrow}</span>
|
|
<div className="section-title-wrap">
|
|
<h2 id="projects-title" className="section-title">
|
|
{copy.title}
|
|
</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={copy.previousLabel}
|
|
>
|
|
{isRtl ? (
|
|
<ArrowRight size={28} strokeWidth={2.4} />
|
|
) : (
|
|
<ArrowLeft size={28} strokeWidth={2.4} />
|
|
)}
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => scrollByCard(1)}
|
|
disabled={!canScrollNext}
|
|
className={`${styles.navButton} ${styles.navButtonNext}`}
|
|
aria-label={copy.nextLabel}
|
|
>
|
|
{isRtl ? (
|
|
<ArrowLeft size={28} strokeWidth={2.4} />
|
|
) : (
|
|
<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={copy.carouselLabel}
|
|
aria-roledescription={copy.carouselRoleDescription}
|
|
>
|
|
<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}>
|
|
{project.link && isRtl && (
|
|
<ArrowUpRight
|
|
size={19}
|
|
strokeWidth={2.5}
|
|
className={styles.titleLinkIcon}
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
<h3
|
|
className={`${styles.cardTitle} ${project.id === 'qlockify' ? 'ltr-token' : ''}`}
|
|
lang={project.id === 'qlockify' ? 'en' : undefined}
|
|
dir={project.id === 'qlockify' ? 'ltr' : undefined}
|
|
>
|
|
{project.title}
|
|
</h3>
|
|
{project.link && !isRtl && (
|
|
<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} ltr-token`}
|
|
lang="en"
|
|
dir="ltr"
|
|
>
|
|
{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
|
|
<div className={styles.cardFooter}>
|
|
<a href={project.link || '#'} className={styles.caseStudyLink}>
|
|
{isRtl && (
|
|
<ArrowLeft size={18} strokeWidth={2.25} aria-hidden="true" />
|
|
)}
|
|
<span>{copy.viewCaseStudy}</span>
|
|
{!isRtl && (
|
|
<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={copy.scrollbarLabel}
|
|
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(${(isRtl ? -1 : 1) * (scrollbarThumbPercent < 100
|
|
? scrollProgress * ((100 - scrollbarThumbPercent) / scrollbarThumbPercent) * 100
|
|
: 0)}%, 0, 0)`,
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|