steelforesight/src/components/ui/apple-cards-carousel.tsx

496 lines
18 KiB
TypeScript

import {
useEffect,
useRef,
useState,
createContext,
useContext,
} from 'react'
import type { ReactNode } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X } from 'lucide-react'
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
function cn(...inputs: Parameters<typeof clsx>) {
return twMerge(clsx(inputs))
}
/* ─── types ─────────────────────────────────────────── */
export interface CardData {
src: string
title: string
category: string
content: ReactNode
/** Optional thumbnail body fields (MUI-style card layout) */
summary?: string
author?: string
date?: string
pages?: number
}
interface CarouselContextType {
onCardClose: (index: number) => void
currentIndex: number
}
/* ─── context ───────────────────────────────────────── */
const CarouselContext = createContext<CarouselContextType>({
onCardClose: () => {},
currentIndex: 0,
})
/* ─── Carousel ──────────────────────────────────────── */
export function Carousel({
items,
initialScroll = 0,
}: {
items: ReactNode[]
initialScroll?: number
}) {
const carouselRef = useRef<HTMLDivElement>(null)
const [currentIndex, setCurrentIndex] = useState(0)
const [paused, setPaused] = useState(false)
useEffect(() => {
if (carouselRef.current) {
carouselRef.current.scrollLeft = initialScroll
}
}, [initialScroll])
/* Auto-advance: scroll one card-width every 4s. Pauses on hover. */
useEffect(() => {
if (paused) return
const el = carouselRef.current
if (!el) return
const id = window.setInterval(() => {
if (!el) return
const step = window.innerWidth < 768 ? 280 : 360 // card width + gap
const atEnd = el.scrollLeft + el.clientWidth >= el.scrollWidth - 4
el.scrollTo({
left: atEnd ? 0 : el.scrollLeft + step,
behavior: 'smooth',
})
}, 4000)
return () => window.clearInterval(id)
}, [paused])
function handleCardClose(index: number) {
if (!carouselRef.current) return
const cardWidth = window.innerWidth < 768 ? 230 : 350
carouselRef.current.scrollTo({ left: (cardWidth + 16) * (index + 1), behavior: 'smooth' })
setCurrentIndex(index)
}
function scrollByStep(direction: -1 | 1) {
if (!carouselRef.current) return
const step = window.innerWidth < 768 ? 280 : 360
carouselRef.current.scrollBy({ left: direction * step, behavior: 'smooth' })
}
return (
<CarouselContext.Provider value={{ onCardClose: handleCardClose, currentIndex }}>
<div
className="relative w-full"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
{/* Scrollable track */}
<div
ref={carouselRef}
className="flex w-full overflow-x-scroll overscroll-x-auto scroll-smooth py-10 md:py-14 [scrollbar-width:none]"
style={{ direction: 'ltr' }}
onTouchStart={() => setPaused(true)}
onTouchEnd={() => setPaused(false)}
>
<div className="flex flex-row justify-start gap-5 px-6 md:px-12 md:pr-24">
{items.map((item, index) => (
<motion.div
key={'card' + index}
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45, delay: 0.08 * index, ease: 'easeOut' }}
>
{item}
</motion.div>
))}
</div>
</div>
{/* ── Glass pill controls — prev/next + auto state ── */}
<div
className="absolute bottom-3 right-6 md:right-12 z-20 flex items-center"
style={{
background: 'rgba(244,239,231,0.5)',
backdropFilter: 'blur(14px) saturate(160%)',
WebkitBackdropFilter: 'blur(14px) saturate(160%)',
border: '1px solid rgba(26,23,18,0.08)',
borderRadius: 9999,
padding: '4px',
boxShadow: '0 4px 16px rgba(26,23,18,0.08)',
gap: 2,
}}
>
{/* Prev */}
<button
aria-label="کارت قبلی"
onClick={() => scrollByStep(-1)}
style={{
width: 36,
height: 36,
borderRadius: '50%',
background: 'transparent',
border: 'none',
cursor: 'pointer',
color: 'var(--ink)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 140ms',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(26,23,18,0.06)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 19l-7-7 7-7" />
</svg>
</button>
{/* Auto state pill */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '0 12px',
height: 36,
fontSize: 9,
fontWeight: 700,
letterSpacing: '2px',
color: 'var(--ink-4)',
fontFamily: 'ui-monospace, monospace',
direction: 'ltr',
whiteSpace: 'nowrap',
userSelect: 'none',
}}
className="max-md:hidden"
>
<span
style={{
width: 6,
height: 6,
borderRadius: '50%',
background: paused ? 'var(--ink-5)' : 'var(--red)',
boxShadow: paused ? 'none' : '0 0 8px rgba(155,28,28,0.55)',
transition: 'background 160ms, box-shadow 160ms',
}}
/>
{paused ? 'PAUSED' : 'AUTO'}
</div>
{/* Next */}
<button
aria-label="کارت بعدی"
onClick={() => scrollByStep(1)}
style={{
width: 36,
height: 36,
borderRadius: '50%',
background: 'transparent',
border: 'none',
cursor: 'pointer',
color: 'var(--ink)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 140ms',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(26,23,18,0.06)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</CarouselContext.Provider>
)
}
/* ─── Card ──────────────────────────────────────────── */
export function Card({
card,
index,
layout = false,
}: {
card: CardData
index: number
layout?: boolean
}) {
const [open, setOpen] = useState(false)
const { onCardClose } = useContext(CarouselContext)
useEffect(() => {
function onKey(e: KeyboardEvent) { if (e.key === 'Escape') handleClose() }
document.body.style.overflow = open ? 'hidden' : 'auto'
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open])
function handleOpen() { setOpen(true) }
function handleClose() { setOpen(false); onCardClose(index) }
return (
<>
{/* ── Modal ── */}
<AnimatePresence>
{open && (
/* Outer: scroll host */
<div className="fixed inset-0 z-50 overflow-y-auto">
{/* Backdrop — click to close */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 bg-black/75 backdrop-blur-md"
onClick={handleClose}
/>
{/* Centering wrapper */}
<div className="flex min-h-full items-center justify-center p-4 md:p-8">
<motion.div
layoutId={layout ? `card-${card.title}` : undefined}
initial={{ opacity: 0, scale: 0.96, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 20 }}
transition={{ duration: 0.3, ease: [0.32, 0.72, 0, 1] }}
className="relative z-10 w-full max-w-2xl bg-white rounded-3xl overflow-hidden shadow-2xl"
style={{ direction: 'rtl', fontFamily: 'Vazir, sans-serif', maxHeight: '88vh', overflowY: 'auto' }}
onClick={e => e.stopPropagation()}
>
{/* Close button */}
<button
onClick={handleClose}
className="absolute top-4 left-4 z-20 h-9 w-9 bg-black/10 hover:bg-black/20 rounded-full flex items-center justify-center transition-colors"
>
<X className="h-4 w-4 text-gray-700" />
</button>
{/* Hero image strip */}
<div className="relative h-56 overflow-hidden" style={{ flexShrink: 0 }}>
<img
src={card.src}
alt={card.title}
className="w-full h-full object-cover"
/>
{/* Stronger gradient — fully opaque at bottom for readable text */}
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/70 via-[40%] to-transparent" />
{/* Category badge — red, top */}
<div className="absolute top-6 right-8 z-10">
<span
className="inline-block text-white px-3 py-1.5 text-[11px] font-bold tracking-[2px] whitespace-nowrap"
style={{ background: 'var(--red)' }}
>
{card.category}
</span>
</div>
{/* Title — bottom, bright white with shadow for clarity */}
<div className="absolute bottom-0 inset-x-0 px-8 pb-6 pt-10">
<p
className="text-white text-xl font-extrabold leading-tight line-clamp-2"
style={{ textShadow: '0 2px 8px rgba(0,0,0,0.6)' }}
>
{card.title}
</p>
</div>
</div>
{/* Body */}
<div className="p-6 md:p-8">
{card.content}
</div>
</motion.div>
</div>
</div>
)}
</AnimatePresence>
{/* ── Card thumbnail — MUI ActionAreaCard pattern, editorial palette ── */}
<motion.button
onClick={handleOpen}
className={cn(
'overflow-hidden flex flex-col relative cursor-pointer shrink-0 text-right',
'w-[260px] h-[440px] md:w-[340px] md:h-[480px]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--red)]',
)}
style={{
direction: 'rtl',
fontFamily: 'Vazir, sans-serif',
background: 'var(--paper)',
border: '1px solid var(--rule-thin)',
borderRadius: 8,
boxShadow: '0 1px 2px rgba(26,23,18,0.04)',
transition: 'box-shadow 200ms, border-color 200ms',
}}
whileHover={{ y: -3 }}
transition={{ duration: 0.2 }}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = '0 12px 32px rgba(26,23,18,0.12)'
e.currentTarget.style.borderColor = 'var(--ink-6)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = '0 1px 2px rgba(26,23,18,0.04)'
e.currentTarget.style.borderColor = 'var(--rule-thin)'
}}
>
{/* Media — image strip */}
<div className="relative w-full" style={{ height: 180, overflow: 'hidden', flexShrink: 0 }}>
<BlurImage
src={card.src}
alt={card.title}
className="object-cover w-full h-full"
/>
{/* Category badge — overlaid bottom-right of image (RTL: visual start) */}
<div className="absolute bottom-3 right-3">
<span
className="inline-block text-white px-3 py-1 text-[10px] font-bold tracking-[2px] whitespace-nowrap"
style={{ background: 'var(--red)' }}
>
{card.category}
</span>
</div>
</div>
{/* Content — title + summary + meta */}
<div className="flex flex-col flex-1 px-6 py-5 max-md:px-5 max-md:py-4" style={{ minHeight: 0 }}>
{/* Title + summary group — vertically centered in remaining space */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 10 }}>
{/* Title */}
<h3
style={{
fontSize: 16,
fontWeight: 800,
color: 'var(--ink)',
letterSpacing: '-0.3px',
lineHeight: 1.4,
overflowWrap: 'normal',
wordBreak: 'normal',
paddingLeft: 8,
paddingRight: 8,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{card.title}
</h3>
{/* Summary */}
{card.summary && (
<p
style={{
fontSize: 13,
color: 'var(--ink-4)',
lineHeight: 1.65,
fontWeight: 400,
overflowWrap: 'normal',
wordBreak: 'normal',
paddingLeft: 8,
paddingRight: 8,
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
margin: 0,
}}
>
{card.summary}
</p>
)}
</div>
{/* Meta footer — tight below summary */}
{(card.author || card.date || card.pages) && (
<div
style={{
marginTop: 16,
paddingTop: 12,
borderTop: '1px solid var(--rule-thin)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 8,
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
{card.author && (
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--ink-2)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{card.author}
</span>
)}
{card.date && (
<span style={{ fontSize: 10, color: 'var(--ink-5)', fontWeight: 500 }}>
{card.date}
</span>
)}
</div>
{card.pages && (
<span
style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: '0.5px',
color: 'var(--red)',
fontVariantNumeric: 'tabular-nums',
whiteSpace: 'nowrap',
paddingRight: 2,
}}
>
{card.pages.toLocaleString('fa-IR')} ص
</span>
)}
</div>
)}
</div>
</motion.button>
</>
)
}
/* ─── BlurImage ─────────────────────────────────────── */
function BlurImage({ src, alt, className }: { src: string; alt: string; className?: string }) {
const [loaded, setLoaded] = useState(false)
return (
<img
src={src}
alt={alt}
onLoad={() => setLoaded(true)}
className={cn('transition-[filter] duration-500', loaded ? 'blur-0' : 'blur-md', className)}
/>
)
}
/* ─── useOutsideClick ───────────────────────────────── */
export function useOutsideClick(
ref: React.RefObject<HTMLDivElement | null>,
callback: () => void,
) {
useEffect(() => {
function listener(e: MouseEvent | TouchEvent) {
if (!ref.current || ref.current.contains(e.target as Node)) return
callback()
}
document.addEventListener('mousedown', listener)
document.addEventListener('touchstart', listener)
return () => {
document.removeEventListener('mousedown', listener)
document.removeEventListener('touchstart', listener)
}
}, [ref, callback])
}