496 lines
18 KiB
TypeScript
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])
|
|
}
|