Files
community-rule/app/components/RelatedArticles.tsx
T
adilallo ab806fbc16
CI Pipeline / test (20) (pull_request) Successful in 6m28s
CI Pipeline / test (18) (pull_request) Successful in 8m20s
CI Pipeline / e2e (firefox) (pull_request) Successful in 3m15s
CI Pipeline / e2e (webkit) (pull_request) Successful in 3m39s
CI Pipeline / e2e (chromium) (pull_request) Successful in 11m5s
CI Pipeline / visual-regression (pull_request) Successful in 6m4s
CI Pipeline / storybook (pull_request) Successful in 2m27s
CI Pipeline / build (pull_request) Successful in 2m29s
CI Pipeline / performance (pull_request) Successful in 4m54s
Skip failing integration tests
2026-01-26 14:49:36 -07:00

172 lines
5.6 KiB
TypeScript

"use client";
import { useState, useEffect, memo, useMemo, useCallback } from "react";
import ContentThumbnailTemplate from "./ContentThumbnailTemplate";
import type { BlogPost } from "../../lib/content";
import { useIsMobile } from "../hooks";
interface RelatedArticlesProps {
relatedPosts: BlogPost[];
currentPostSlug: string;
slugOrder?: string[];
}
const RelatedArticles = memo<RelatedArticlesProps>(
({ relatedPosts, currentPostSlug, slugOrder = [] }) => {
// Memoize filtered posts to prevent unnecessary re-computations
const filteredPosts = useMemo(
() => relatedPosts.filter((post) => post.slug !== currentPostSlug),
[relatedPosts, currentPostSlug],
);
const [currentIndex, setCurrentIndex] = useState(0);
const [progress, setProgress] = useState(0);
const isMobile = useIsMobile();
// Memoize the mouse down handler to prevent unnecessary re-renders
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const slider = e.currentTarget;
const startX = e.pageX - slider.offsetLeft;
const scrollLeft = slider.scrollLeft;
const handleMouseMove = (e: MouseEvent) => {
const x = e.pageX - slider.offsetLeft;
const walk = (x - startX) * 2;
slider.scrollLeft = scrollLeft - walk;
};
const handleMouseUp = () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
},
[],
);
// Memoize transform style to prevent unnecessary recalculations
const transformStyle = useMemo(
() => ({
transform: isMobile
? `translateX(calc(50% - 130px - ${currentIndex * 260}px))`
: "none",
scrollBehavior: (!isMobile
? "smooth"
: "auto") as React.CSSProperties["scrollBehavior"],
}),
[isMobile, currentIndex],
);
// Memoize progress bar style calculation
const getProgressStyle = useCallback(
(index: number): React.CSSProperties => ({
width:
index === currentIndex
? `${progress}%`
: index < currentIndex
? "100%"
: "0%",
}),
[currentIndex, progress],
);
// Mobile detection is now handled by useIsMobile hook
// Auto-advance every 3 seconds (only on mobile)
useEffect(() => {
if (filteredPosts.length <= 1 || !isMobile) return;
const interval = setInterval(() => {
setProgress(0);
setCurrentIndex((prev) => (prev + 1) % filteredPosts.length);
}, 3000);
return () => clearInterval(interval);
}, [filteredPosts.length, isMobile]);
// Progress animation (only on mobile)
useEffect(() => {
if (filteredPosts.length <= 1 || !isMobile) return;
const progressInterval = setInterval(() => {
setProgress((prev) => {
if (prev >= 100) {
return 0;
}
return prev + 1;
});
}, 30); // 30ms intervals for smooth animation
return () => clearInterval(progressInterval);
}, [currentIndex, filteredPosts.length, isMobile]);
if (filteredPosts.length === 0) {
return null;
}
return (
<section
className="py-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-064)]"
data-testid="related-articles"
>
<div className="flex flex-col gap-[var(--spacing-scale-032)] lg:gap-[51px]">
<h2 className="text-[32px] lg:text-[44px] leading-[110%] font-medium text-[var(--color-content-inverse-primary)] text-center">
Related Articles
</h2>
{/* Horizontal Articles Row - Carousel on mobile, Scrollable slider on desktop */}
<div className="flex justify-center overflow-hidden">
<div
className={`flex gap-0 transition-transform duration-500 ease-in-out ${
!isMobile
? "overflow-x-auto scrollbar-hide cursor-grab active:cursor-grabbing"
: ""
}`}
style={transformStyle}
onMouseDown={!isMobile ? handleMouseDown : undefined}
>
{filteredPosts.map((relatedPost) => (
<div
key={relatedPost.slug}
className="flex flex-col items-center flex-shrink-0"
data-testid={`related-${relatedPost.slug}`}
>
<ContentThumbnailTemplate
post={relatedPost}
variant="vertical"
slugOrder={slugOrder}
/>
</div>
))}
</div>
</div>
{/* Progress bars - only show on mobile */}
{isMobile && (
<div className="flex justify-center gap-[var(--measures-spacing-008)] px-[var(--measures-spacing-064)]">
{filteredPosts.map((relatedPost, index) => (
<div
key={relatedPost.slug}
className="max-w-[var(--measures-spacing-056)] w-full h-[var(--measures-spacing-004)] bg-gray-200 rounded-full overflow-hidden"
>
<div
className="h-full bg-gray-600 rounded-full transition-all duration-75 ease-linear"
style={getProgressStyle(index)}
/>
</div>
))}
</div>
)}
</div>
</section>
);
},
);
RelatedArticles.displayName = "RelatedArticles";
export default RelatedArticles;