/**
* SubstackArticlesCarousel Component
*
* Auto-scrolling carousel for Substack articles on user profiles
* Features: 3 cards on desktop, peek effect on mobile, auto-scroll,
* swipe gestures, page indicators, and Substack branding
*/
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { SubstackArticle } from './SubstackArticleCard';
import Image from 'next/image';
import Link from 'next/link';
import { ChevronLeft, ChevronRight, Clock, Star, ExternalLink, Loader2 } from 'lucide-react';
import { cn } from '@canadagpt/design-system';
import { formatDistanceToNow } from 'date-fns';
interface SubstackProfile {
substack_url: string;
author_name: string | null;
subscribe_button_enabled: boolean;
subscribe_button_text: string;
}
interface SubstackArticlesCarouselProps {
username: string;
autoScrollInterval?: number;
}
/**
* Loading skeleton for article cards
*/
function ArticleSkeleton() {
return (
<div className="flex-shrink-0 w-[280px] sm:w-[320px] bg-white dark:bg-gray-800 rounded-lg overflow-hidden shadow-md animate-pulse">
<div className="h-40 sm:h-44 bg-gray-200 dark:bg-gray-700" />
<div className="p-4">
<div className="h-5 w-3/4 bg-gray-200 dark:bg-gray-700 rounded mb-2" />
<div className="h-4 w-full bg-gray-200 dark:bg-gray-700 rounded mb-1" />
<div className="h-4 w-2/3 bg-gray-200 dark:bg-gray-700 rounded mb-3" />
<div className="flex gap-3">
<div className="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-20 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
</div>
</div>
);
}
/**
* Individual article card for carousel
*/
function CarouselArticleCard({ article }: { article: SubstackArticle }) {
const publishedDate = formatDistanceToNow(new Date(article.published_at), {
addSuffix: true,
});
return (
<a
href={article.article_url}
target="_blank"
rel="noopener noreferrer"
className={cn(
'flex-shrink-0 w-[280px] sm:w-[320px]',
'bg-white dark:bg-gray-800',
'rounded-lg overflow-hidden shadow-md',
'hover:shadow-xl hover:-translate-y-1',
'transition-all duration-300',
'group block'
)}
>
{/* Cover Image */}
<div className="relative h-40 sm:h-44 bg-gray-100 dark:bg-gray-700">
{article.cover_image_url ? (
<Image
src={article.cover_image_url}
alt={article.title}
fill
className="object-cover group-hover:scale-105 transition-transform duration-300"
sizes="320px"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-orange-100 to-red-100 dark:from-orange-900/20 dark:to-red-900/20">
<span className="text-4xl text-orange-300 dark:text-orange-700">S</span>
</div>
)}
{article.is_featured && (
<span className="absolute top-3 right-3 px-2 py-1 bg-amber-500 text-white text-xs font-semibold rounded-full flex items-center gap-1 shadow-lg">
<Star className="w-3 h-3" />
Featured
</span>
)}
</div>
{/* Content */}
<div className="p-4">
<h3 className="font-semibold text-gray-900 dark:text-white group-hover:text-orange-600 dark:group-hover:text-orange-400 transition-colors line-clamp-2 text-sm sm:text-base">
{article.title}
<ExternalLink className="inline-block w-3 h-3 ml-1 opacity-0 group-hover:opacity-100 transition-opacity" />
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2 mt-1.5">
{article.excerpt}
</p>
<div className="flex items-center gap-3 mt-3 text-xs text-gray-500 dark:text-gray-400">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{article.read_time_minutes} min read
</span>
<span>{publishedDate}</span>
</div>
</div>
</a>
);
}
/**
* Substack logo SVG
*/
function SubstackLogo({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M22.539 8.242H1.46V5.406h21.08v2.836zM1.46 10.812V24L12 18.11 22.54 24V10.812H1.46zM22.54 0H1.46v2.836h21.08V0z" />
</svg>
);
}
export function SubstackArticlesCarousel({
username,
autoScrollInterval = 5000,
}: SubstackArticlesCarouselProps) {
const [articles, setArticles] = useState<SubstackArticle[]>([]);
const [profile, setProfile] = useState<SubstackProfile | null>(null);
const [loading, setLoading] = useState(true);
const [currentIndex, setCurrentIndex] = useState(0);
const [isPaused, setIsPaused] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const touchStartX = useRef<number>(0);
// Fetch articles
useEffect(() => {
async function fetchArticles() {
try {
const response = await fetch(
`/api/substack/articles?username=${username}&limit=9`
);
if (!response.ok) {
throw new Error('Failed to fetch articles');
}
const data = await response.json();
setArticles(data.articles || []);
setProfile(data.profile);
} catch (err) {
console.error('Error fetching Substack articles:', err);
} finally {
setLoading(false);
}
}
fetchArticles();
}, [username]);
// Calculate pages (3 tiles per page on desktop)
const tilesPerPage = 3;
const totalPages = Math.ceil(articles.length / tilesPerPage);
// Auto-scroll
useEffect(() => {
if (isPaused || totalPages <= 1) return;
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % totalPages);
}, autoScrollInterval);
return () => clearInterval(interval);
}, [isPaused, totalPages, autoScrollInterval]);
// Scroll to current page
useEffect(() => {
if (containerRef.current && articles.length > 0) {
const tileWidth = 320 + 12; // tile width + gap
containerRef.current.scrollTo({
left: currentIndex * tilesPerPage * tileWidth,
behavior: 'smooth',
});
}
}, [currentIndex, articles.length]);
// Navigation handlers
const goToPage = useCallback(
(page: number) => {
setCurrentIndex(Math.max(0, Math.min(page, totalPages - 1)));
},
[totalPages]
);
const goNext = useCallback(() => {
setCurrentIndex((prev) => (prev + 1) % totalPages);
}, [totalPages]);
const goPrev = useCallback(() => {
setCurrentIndex((prev) => (prev - 1 + totalPages) % totalPages);
}, [totalPages]);
// Touch handlers for swipe
const handleTouchStart = (e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX;
setIsPaused(true);
};
const handleTouchEnd = (e: React.TouchEvent) => {
const touchEndX = e.changedTouches[0].clientX;
const diff = touchStartX.current - touchEndX;
if (Math.abs(diff) > 50) {
if (diff > 0) {
goNext();
} else {
goPrev();
}
}
// Resume auto-scroll after delay
setTimeout(() => setIsPaused(false), 5000);
};
// Loading state
if (loading) {
return (
<div className="bg-gradient-to-br from-orange-50 to-red-50 dark:from-gray-800 dark:to-gray-900 rounded-xl p-6">
<div className="flex items-center gap-2 mb-4">
<SubstackLogo className="w-5 h-5 text-orange-500" />
<div className="h-6 w-40 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
</div>
<div className="flex gap-3 overflow-hidden">
<ArticleSkeleton />
<ArticleSkeleton />
<ArticleSkeleton />
</div>
</div>
);
}
// Don't render if no Substack profile or no articles
if (!profile || articles.length === 0) {
return null;
}
return (
<div
className="bg-gradient-to-br from-orange-50 to-red-50 dark:from-gray-800 dark:to-gray-900 rounded-xl p-6"
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<SubstackLogo className="w-5 h-5 text-orange-500" />
<div>
<h2 className="text-lg sm:text-xl font-bold text-gray-900 dark:text-white">
Featured Articles
</h2>
{profile.author_name && (
<p className="text-sm text-gray-600 dark:text-gray-400">
by {profile.author_name}
</p>
)}
</div>
</div>
<div className="flex items-center gap-3">
{/* Navigation controls */}
{totalPages > 1 && (
<div className="hidden sm:flex items-center gap-2">
<button
onClick={goPrev}
className="p-1.5 rounded-full hover:bg-black/10 dark:hover:bg-white/10 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"
aria-label="Previous"
>
<ChevronLeft size={20} />
</button>
{/* Page indicators */}
<div className="flex gap-1.5">
{Array.from({ length: totalPages }).map((_, i) => (
<button
key={i}
onClick={() => goToPage(i)}
className={cn(
'w-2 h-2 rounded-full transition-all',
i === currentIndex
? 'bg-orange-500 w-4'
: 'bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500'
)}
aria-label={`Page ${i + 1}`}
/>
))}
</div>
<button
onClick={goNext}
className="p-1.5 rounded-full hover:bg-black/10 dark:hover:bg-white/10 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"
aria-label="Next"
>
<ChevronRight size={20} />
</button>
</div>
)}
{/* Subscribe Button */}
{profile.subscribe_button_enabled && (
<a
href={profile.substack_url}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 bg-orange-500 hover:bg-orange-600 text-white font-medium rounded-lg transition-colors text-sm shadow-md hover:shadow-lg"
>
{profile.subscribe_button_text || 'Subscribe'}
</a>
)}
</div>
</div>
{/* Carousel */}
<div
ref={containerRef}
className="flex gap-3 overflow-x-auto scrollbar-hide scroll-smooth pb-2"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{articles.map((article) => (
<CarouselArticleCard key={article.id} article={article} />
))}
</div>
{/* Mobile page indicators */}
{totalPages > 1 && (
<div className="flex sm:hidden justify-center gap-1.5 mt-4">
{Array.from({ length: totalPages }).map((_, i) => (
<button
key={i}
onClick={() => goToPage(i)}
className={cn(
'w-2 h-2 rounded-full transition-all',
i === currentIndex
? 'bg-orange-500 w-4'
: 'bg-gray-300 dark:bg-gray-600'
)}
aria-label={`Page ${i + 1}`}
/>
))}
</div>
)}
{/* View All Link */}
<div className="mt-4 text-center">
<a
href={profile.substack_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-orange-600 dark:text-orange-400 hover:underline font-medium"
>
View all on Substack
<ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
);
}
export default SubstackArticlesCarousel;