feat: render locale-aware portfolio routes
This commit is contained in:
@@ -1,21 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ArrowLeft, ArrowRight, ArrowUpRight } from 'lucide-react';
|
||||
import { projects } from '@/data/projects';
|
||||
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;
|
||||
|
||||
export const ProjectsSection: React.FC = () => {
|
||||
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,
|
||||
startScrollLeft: 0,
|
||||
startLogicalScrollLeft: 0,
|
||||
lastX: 0,
|
||||
lastTime: 0,
|
||||
velocity: 0,
|
||||
@@ -36,14 +52,15 @@ export const ProjectsSection: React.FC = () => {
|
||||
const [canScrollPrev, setCanScrollPrev] = useState(false);
|
||||
const [canScrollNext, setCanScrollNext] = useState(true);
|
||||
|
||||
const syncScrollState = () => {
|
||||
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(viewport.scrollLeft / maxScroll, 1)) : 0,
|
||||
maxScroll > 0 ? Math.max(0, Math.min(logicalScroll / maxScroll, 1)) : 0,
|
||||
);
|
||||
setScrollbarThumbPercent(
|
||||
Math.max(
|
||||
@@ -51,9 +68,9 @@ export const ProjectsSection: React.FC = () => {
|
||||
Math.min((viewport.clientWidth / viewport.scrollWidth) * 100, 100),
|
||||
),
|
||||
);
|
||||
setCanScrollPrev(viewport.scrollLeft > 1);
|
||||
setCanScrollNext(viewport.scrollLeft < maxScroll - 1);
|
||||
};
|
||||
setCanScrollPrev(logicalScroll > 1);
|
||||
setCanScrollNext(logicalScroll < maxScroll - 1);
|
||||
}, [isRtl]);
|
||||
|
||||
useEffect(() => {
|
||||
syncScrollState();
|
||||
@@ -63,7 +80,7 @@ export const ProjectsSection: React.FC = () => {
|
||||
const resizeObserver = new ResizeObserver(syncScrollState);
|
||||
resizeObserver.observe(viewport);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
}, [syncScrollState]);
|
||||
|
||||
const scrollByCard = (direction: -1 | 1) => {
|
||||
const viewport = viewportRef.current;
|
||||
@@ -72,10 +89,16 @@ export const ProjectsSection: React.FC = () => {
|
||||
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 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>) => {
|
||||
@@ -89,7 +112,7 @@ export const ProjectsSection: React.FC = () => {
|
||||
moved: false,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
startScrollLeft: viewport.scrollLeft,
|
||||
startLogicalScrollLeft: getLogicalScrollLeft(viewport, isRtl),
|
||||
lastX: event.clientX,
|
||||
lastTime: performance.now(),
|
||||
velocity: 0,
|
||||
@@ -135,7 +158,11 @@ export const ProjectsSection: React.FC = () => {
|
||||
dragState.current.lastTime = now;
|
||||
|
||||
if (Math.abs(deltaX) > 4) dragState.current.moved = true;
|
||||
viewport.scrollLeft = dragState.current.startScrollLeft - deltaX;
|
||||
setLogicalScrollLeft(
|
||||
viewport,
|
||||
dragState.current.startLogicalScrollLeft - deltaX,
|
||||
isRtl,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -175,7 +202,7 @@ export const ProjectsSection: React.FC = () => {
|
||||
const step = cardWidth + gap;
|
||||
if (step <= 0) return;
|
||||
|
||||
const currentScroll = viewport.scrollLeft;
|
||||
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) {
|
||||
@@ -190,10 +217,7 @@ export const ProjectsSection: React.FC = () => {
|
||||
const maxIndex = projects.length - 1;
|
||||
targetIndex = Math.max(0, Math.min(targetIndex, maxIndex));
|
||||
|
||||
viewport.scrollTo({
|
||||
left: targetIndex * step,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
setLogicalScrollLeft(viewport, targetIndex * step, isRtl, 'smooth');
|
||||
};
|
||||
|
||||
const handleClickCapture = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
@@ -207,10 +231,10 @@ export const ProjectsSection: React.FC = () => {
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
scrollByCard(-1);
|
||||
scrollByCard(isRtl ? 1 : -1);
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
scrollByCard(1);
|
||||
scrollByCard(isRtl ? -1 : 1);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -229,13 +253,16 @@ export const ProjectsSection: React.FC = () => {
|
||||
const maxScroll = Math.max(0, viewport.scrollWidth - viewport.clientWidth);
|
||||
if (availableTrack <= 0 || maxScroll <= 0) return;
|
||||
|
||||
const thumbLeft = Math.max(
|
||||
const pointerFromOrigin = isRtl
|
||||
? trackRect.right - clientX
|
||||
: clientX - trackRect.left;
|
||||
const thumbOffset = Math.max(
|
||||
0,
|
||||
Math.min(clientX - trackRect.left - grabOffset, availableTrack),
|
||||
Math.min(pointerFromOrigin - grabOffset, availableTrack),
|
||||
);
|
||||
const nextProgress = thumbLeft / availableTrack;
|
||||
const nextProgress = thumbOffset / availableTrack;
|
||||
setScrollProgress(nextProgress);
|
||||
viewport.scrollLeft = nextProgress * maxScroll;
|
||||
setLogicalScrollLeft(viewport, nextProgress * maxScroll, isRtl);
|
||||
};
|
||||
|
||||
const handleScrollbarPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
@@ -248,7 +275,9 @@ export const ProjectsSection: React.FC = () => {
|
||||
const thumbRect = thumb.getBoundingClientRect();
|
||||
const clickedThumb = thumb.contains(event.target as Node);
|
||||
const grabOffset = clickedThumb
|
||||
? event.clientX - thumbRect.left
|
||||
? isRtl
|
||||
? thumbRect.right - event.clientX
|
||||
: event.clientX - thumbRect.left
|
||||
: thumbRect.width / 2;
|
||||
|
||||
scrollbarDragState.current = {
|
||||
@@ -289,16 +318,18 @@ export const ProjectsSection: React.FC = () => {
|
||||
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
scrollByCard(-1);
|
||||
scrollByCard(isRtl ? 1 : -1);
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
scrollByCard(1);
|
||||
scrollByCard(isRtl ? -1 : 1);
|
||||
} else if (event.key === 'Home' || event.key === 'End') {
|
||||
event.preventDefault();
|
||||
viewport.scrollTo({
|
||||
left: event.key === 'Home' ? 0 : viewport.scrollWidth - viewport.clientWidth,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
setLogicalScrollLeft(
|
||||
viewport,
|
||||
event.key === 'Home' ? 0 : viewport.scrollWidth - viewport.clientWidth,
|
||||
isRtl,
|
||||
'smooth',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -311,10 +342,10 @@ export const ProjectsSection: React.FC = () => {
|
||||
<div className="content-container">
|
||||
<div className={styles.headerRow} data-reveal="soft">
|
||||
<div className="section-header" style={{ marginBottom: 0 }}>
|
||||
<span className="section-eyebrow">PROJECTS</span>
|
||||
<span className="section-eyebrow">{copy.eyebrow}</span>
|
||||
<div className="section-title-wrap">
|
||||
<h2 id="projects-title" className="section-title">
|
||||
Products I've Brought to Life
|
||||
{copy.title}
|
||||
</h2>
|
||||
<div className="section-underline" />
|
||||
</div>
|
||||
@@ -326,9 +357,13 @@ export const ProjectsSection: React.FC = () => {
|
||||
onClick={() => scrollByCard(-1)}
|
||||
disabled={!canScrollPrev}
|
||||
className={`${styles.navButton} ${styles.navButtonPrevious}`}
|
||||
aria-label="Previous projects"
|
||||
aria-label={copy.previousLabel}
|
||||
>
|
||||
<ArrowLeft size={28} strokeWidth={2.4} />
|
||||
{isRtl ? (
|
||||
<ArrowRight size={28} strokeWidth={2.4} />
|
||||
) : (
|
||||
<ArrowLeft size={28} strokeWidth={2.4} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -336,9 +371,13 @@ export const ProjectsSection: React.FC = () => {
|
||||
onClick={() => scrollByCard(1)}
|
||||
disabled={!canScrollNext}
|
||||
className={`${styles.navButton} ${styles.navButtonNext}`}
|
||||
aria-label="Next projects"
|
||||
aria-label={copy.nextLabel}
|
||||
>
|
||||
<ArrowRight size={28} strokeWidth={2.4} />
|
||||
{isRtl ? (
|
||||
<ArrowLeft size={28} strokeWidth={2.4} />
|
||||
) : (
|
||||
<ArrowRight size={28} strokeWidth={2.4} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -355,8 +394,8 @@ export const ProjectsSection: React.FC = () => {
|
||||
onClickCapture={handleClickCapture}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
aria-label="Scrollable project cards"
|
||||
aria-roledescription="carousel"
|
||||
aria-label={copy.carouselLabel}
|
||||
aria-roledescription={copy.carouselRoleDescription}
|
||||
>
|
||||
<div className={styles.carouselTrack} data-reveal-group>
|
||||
{projects.map((project) => (
|
||||
@@ -372,8 +411,22 @@ export const ProjectsSection: React.FC = () => {
|
||||
|
||||
<div className={styles.cardBody} data-reveal="soft">
|
||||
<div className={styles.cardTitleRow}>
|
||||
<h3 className={styles.cardTitle}>{project.title}</h3>
|
||||
{project.link && (
|
||||
{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}
|
||||
@@ -388,7 +441,12 @@ export const ProjectsSection: React.FC = () => {
|
||||
|
||||
<div className={styles.tagList}>
|
||||
{project.tags.map((tag) => (
|
||||
<span key={tag} className={styles.tagBadge}>
|
||||
<span
|
||||
key={tag}
|
||||
className={`${styles.tagBadge} ltr-token`}
|
||||
lang="en"
|
||||
dir="ltr"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
@@ -396,8 +454,13 @@ export const ProjectsSection: React.FC = () => {
|
||||
|
||||
<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" />
|
||||
{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>
|
||||
@@ -411,7 +474,7 @@ export const ProjectsSection: React.FC = () => {
|
||||
className={`${styles.scrollbarTrack} ${isScrollbarDragging ? styles.scrollbarDragging : ''}`}
|
||||
role="scrollbar"
|
||||
tabIndex={0}
|
||||
aria-label="Project carousel position"
|
||||
aria-label={copy.scrollbarLabel}
|
||||
aria-controls="projects-carousel"
|
||||
aria-orientation="horizontal"
|
||||
aria-valuemin={0}
|
||||
@@ -427,9 +490,9 @@ export const ProjectsSection: React.FC = () => {
|
||||
className={styles.scrollbarThumb}
|
||||
style={{
|
||||
width: `${scrollbarThumbPercent}%`,
|
||||
transform: `translate3d(${scrollbarThumbPercent < 100
|
||||
transform: `translate3d(${(isRtl ? -1 : 1) * (scrollbarThumbPercent < 100
|
||||
? scrollProgress * ((100 - scrollbarThumbPercent) / scrollbarThumbPercent) * 100
|
||||
: 0}%, 0, 0)`,
|
||||
: 0)}%, 0, 0)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user