Second pass on component refactor
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, memo, useMemo, useCallback } from "react";
|
||||
import { useIsMobile } from "../../hooks";
|
||||
import { RelatedArticlesView } from "./RelatedArticles.view";
|
||||
import type { RelatedArticlesProps } from "./RelatedArticles.types";
|
||||
|
||||
const RelatedArticlesContainer = 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],
|
||||
);
|
||||
|
||||
// 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]);
|
||||
|
||||
return (
|
||||
<RelatedArticlesView
|
||||
filteredPosts={filteredPosts}
|
||||
slugOrder={slugOrder}
|
||||
isMobile={isMobile}
|
||||
transformStyle={transformStyle}
|
||||
getProgressStyle={getProgressStyle}
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
RelatedArticlesContainer.displayName = "RelatedArticles";
|
||||
|
||||
export default RelatedArticlesContainer;
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { BlogPost } from "../../../lib/content";
|
||||
|
||||
export interface RelatedArticlesProps {
|
||||
relatedPosts: BlogPost[];
|
||||
currentPostSlug: string;
|
||||
slugOrder?: string[];
|
||||
}
|
||||
|
||||
export interface RelatedArticlesViewProps {
|
||||
filteredPosts: BlogPost[];
|
||||
slugOrder: string[];
|
||||
isMobile: boolean;
|
||||
transformStyle: React.CSSProperties;
|
||||
getProgressStyle: (index: number) => React.CSSProperties;
|
||||
onMouseDown?: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import ContentThumbnailTemplate from "../ContentThumbnailTemplate";
|
||||
import type { RelatedArticlesViewProps } from "./RelatedArticles.types";
|
||||
|
||||
export function RelatedArticlesView({
|
||||
filteredPosts,
|
||||
slugOrder,
|
||||
isMobile,
|
||||
transformStyle,
|
||||
getProgressStyle,
|
||||
onMouseDown,
|
||||
}: RelatedArticlesViewProps) {
|
||||
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 ? onMouseDown : 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./RelatedArticles.container";
|
||||
export type { RelatedArticlesProps } from "./RelatedArticles.types";
|
||||
Reference in New Issue
Block a user