Files
reui-blocks/blocks/gallery35.tsx
T

403 lines
11 KiB
TypeScript

"use client";
import type { MotionValue } from "framer-motion";
import {
motion,
useAnimationControls,
useScroll,
useTransform,
} from "framer-motion";
import { Pause, Play, RotateCcw } from "lucide-react";
import React, { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
const getTextPositionClassName = (
textPosition: "left" | "right" | "top" | "bottom",
) => {
let textPositionClassName = "";
switch (textPosition) {
case "left":
textPositionClassName = "md:left-10 md:top-1/3 md:-translate-y-1/3";
break;
case "right":
textPositionClassName = "md:right-10 md:top-1/2 md:-translate-y-1/2";
break;
case "top":
textPositionClassName = "md:top-10 md:left-1/2 md:-translate-x-1/2";
break;
case "bottom":
textPositionClassName = "md:bottom-10 md:left-1/2 md:-translate-x-1/2";
break;
default:
textPositionClassName = "md:left-10 md:top-1/2 md:-translate-y-1/2";
break;
}
return textPositionClassName;
};
const getCardPositions = (total: number, index: number) => {
if (total <= 1) {
return [0, 0, 0, 1, 1];
}
const step = 1 / total;
const center = step * index + step / 2;
const range = [
0,
Math.max(0, center - step),
center,
Math.min(1, center + step),
1,
];
for (let i = 1; i < range.length; i++) {
range[i] = Math.max(range[i], range[i - 1]);
}
return range;
};
interface Visual {
text: string;
className: string;
image: string;
textPosition: "left" | "right" | "top" | "bottom";
}
interface VisualCardProps {
visual: Visual;
index: number;
total: number;
scrollXProgress: MotionValue<number>;
}
const VisualCard = ({
visual,
index,
total,
scrollXProgress,
}: VisualCardProps) => {
const textPositionClassName = getTextPositionClassName(visual.textPosition);
const range = getCardPositions(total, index);
const opacity = useTransform(scrollXProgress, range, [0, 0, 1, 0, 0]);
const x = useTransform(scrollXProgress, range, [
"100%",
"100%",
"0%",
"-100%",
"-100%",
]);
return (
<motion.div
className={cn(
"relative h-[75vh] w-[70vw] shrink-0 snap-center overflow-hidden rounded-3xl",
visual.className,
)}
id={`visual-${index}`}
>
<img
src={visual.image}
alt={visual.text}
className={cn("absolute inset-0 z-0 h-full w-full object-cover")}
/>
<span className="absolute inset-0 z-10 bg-black/5" />
<motion.p
style={{
opacity,
x,
}}
dir="rtl"
className={cn(
"absolute z-20 hidden text-right text-xl font-semibold leading-relaxed md:block md:max-w-sm lg:text-xl",
textPositionClassName,
)}
>
{visual.text}
</motion.p>
<motion.p
style={{
opacity,
x,
}}
dir="rtl"
className={cn(
"absolute z-20 text-right text-xl font-semibold leading-relaxed md:hidden md:max-w-sm lg:text-xl",
"top-10 left-1/2 -translate-x-1/2 px-4",
)}
>
{visual.text}
</motion.p>
</motion.div>
);
};
const GalleryPaginationDot = ({
index,
scrollXProgress,
total,
isPlaying,
currentVisualIndex,
}: {
index: number;
scrollXProgress: MotionValue<number>;
total: number;
isPlaying: boolean;
currentVisualIndex: number;
}) => {
const range = getCardPositions(total, index);
const controls = useAnimationControls();
const width = useTransform(scrollXProgress, range, [8, 8, 50, 8, 8]);
useEffect(() => {
if (isPlaying && currentVisualIndex === index) {
controls.set({
width: 0,
});
controls.start({
width: "100%",
});
}
}, [currentVisualIndex, isPlaying, index, controls]);
const handleClick = (index: number) => {
document.getElementById(`visual-${index}`)?.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "center",
});
};
return (
<motion.button
type="button"
style={{
width,
}}
onClick={() => handleClick(index)}
aria-label={`رفتن به اسلاید ${new Intl.NumberFormat("fa-IR").format(index + 1)}`}
className="relative inline-block h-2 rounded-full bg-foreground/50 hover:bg-foreground/70"
>
{isPlaying && currentVisualIndex === index && (
<motion.span
animate={controls}
transition={{ duration: 2, ease: "linear" }}
className="absolute top-0 start-0 z-10 h-full rounded-full bg-foreground"
/>
)}
</motion.button>
);
};
interface Gallery35Props {
heading?: string;
visuals?: Visual[];
className?: string;
}
const defaultVisuals: Visual[] = [
{
text: "اصول طراحی مدرن رابط‌هایی شهودی می‌سازد که کاربران به آن اعتماد و علاقه دارند.",
className: "bg-blue-100 dark:bg-blue-900",
image: "/block-media/shadcnblocks/block/guri4/img11.png",
textPosition: "left",
},
{
text: "فرایند خلاق نوآوری را با طراحی تکراری و بازخورد کاربر پیش می‌برد.",
className: "bg-green-100 dark:bg-green-900",
image: "/block-media/shadcnblocks/block/guri3/img5.jpeg",
textPosition: "top",
},
{
text: "تحلیل بصری داده‌های پیچیده را به داشبوردهای واضح و قابل‌اقدام تبدیل می‌کند.",
className: "bg-purple-100 dark:bg-purple-900",
image:
"/block-media/shadcnblocks/block/photos/simone-hutsch-uR__S5GX8Io-unsplash.jpg",
textPosition: "bottom",
},
{
text: "هنر دیجیتال زیبایی‌شناسی کلاسیک را با قابلیت‌های تعاملی مدرن ترکیب می‌کند.",
className: "bg-pink-100 dark:bg-pink-900",
image: "/block-media/shadcnblocks/block/lummi/bw15.jpeg",
textPosition: "top",
},
{
text: "مرکز نوآوری فناوری پیشرفته را با طراحی انسان‌محور در کنار هم قرار می‌دهد.",
className: "bg-orange-100 dark:bg-orange-900",
image: "/block-media/shadcnblocks/block/guri3/img23.jpeg",
textPosition: "left",
},
{
text: "تحلیل تصویری داده‌ها، مسیر مشتری را از آگاهی تا تبدیل در یک نمای بصری نمایش می‌دهد.",
className: "bg-indigo-100 dark:bg-indigo-900",
image: "/block-media/shadcnblocks/block/guri3/img4.jpeg",
textPosition: "top",
},
];
const Gallery35 = ({
className,
heading = "گالری محصول",
visuals = defaultVisuals,
}: Gallery35Props) => {
const containerRef = useRef<HTMLDivElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentVisualIndex, setCurrentVisualIndex] = useState(0);
const { scrollXProgress } = useScroll({
container: containerRef,
});
useEffect(() => {
const unsubscribe = scrollXProgress.on("change", (progress) => {
if (isPlaying) return;
let closestIndex = 0;
let minDistance = Infinity;
visuals.forEach((_, index) => {
const range = getCardPositions(visuals.length, index);
const visualPosition = range[2];
const distance = Math.abs(progress - visualPosition);
if (distance < minDistance) {
minDistance = distance;
closestIndex = index;
}
});
setCurrentVisualIndex(closestIndex);
});
return () => unsubscribe();
}, [scrollXProgress, visuals.length, isPlaying, visuals]);
useEffect(() => {
if (!isPlaying) return;
const intervalId = setInterval(() => {
if (currentVisualIndex === visuals.length - 1) {
setIsPlaying(false);
return currentVisualIndex;
}
const nextIndex = currentVisualIndex + 1;
document.getElementById(`visual-${nextIndex}`)?.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "center",
});
setCurrentVisualIndex(nextIndex);
}, 2000);
return () => clearInterval(intervalId);
}, [isPlaying, visuals.length, currentVisualIndex]);
const restartAnimation = () => {
setCurrentVisualIndex(0);
document.getElementById(`visual-0`)?.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "center",
});
setIsPlaying(true);
};
const isLastSlide =
currentVisualIndex === visuals.length - 1 && !isPlaying;
return (
<section dir="rtl" lang="fa" className={cn("py-32", className)}>
<div className="container overflow-visible">
<div className="flex flex-col gap-14">
<h3 className="text-right text-3xl font-semibold tracking-tight md:text-xl lg:text-2xll">
{heading}
</h3>
<div className="relative space-y-10">
<div
ref={containerRef}
dir="rtl"
className="hide-scrollbar flex snap-x snap-mandatory flex-nowrap items-center gap-10 overflow-x-auto scroll-smooth"
style={{
scrollSnapType: "x mandatory",
scrollBehavior: "smooth",
WebkitOverflowScrolling: "touch",
}}
>
{visuals.map((visual, index) => {
return (
<VisualCard
key={`visual-${index}`}
visual={visual}
index={index}
total={visuals.length}
scrollXProgress={scrollXProgress}
/>
);
})}
</div>
<div className="sticky bottom-20 z-20 flex justify-center">
<div className="flex h-14 items-center gap-2 overflow-hidden">
<div className="flex h-full items-center gap-3 rounded-full bg-muted/50 px-6 backdrop-blur-xl">
{visuals.map((_, index) => {
return (
<GalleryPaginationDot
key={`visual-${index}`}
index={index}
scrollXProgress={scrollXProgress}
total={visuals.length}
isPlaying={isPlaying}
currentVisualIndex={currentVisualIndex}
/>
);
})}
</div>
<button
type="button"
onClick={
isLastSlide ? restartAnimation : () => setIsPlaying(!isPlaying)
}
aria-label={
isLastSlide
? "شروع دوباره گالری"
: isPlaying
? "توقف پخش خودکار"
: "پخش خودکار گالری"
}
className="flex h-full items-center justify-center rounded-full bg-muted/50 px-6 backdrop-blur-xl [&>svg]:fill-foreground/50 [&>svg]:stroke-foreground/10"
>
{isLastSlide ? (
<RotateCcw
strokeWidth={2.2}
className="fill-none! stroke-foreground/50!"
/>
) : isPlaying ? (
<Pause />
) : (
<Play />
)}
</button>
</div>
</div>
</div>
</div>
</div>
</section>
);
};
export { Gallery35 };