diff --git a/src/app/page.tsx b/src/app/page.tsx index 007b782..c227399 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -51,7 +51,6 @@ export default async function HomePage() { siteContent={siteContent} events={selectedEvents.events} posts={posts} - isEventArchive={selectedEvents.isArchive} /> ); diff --git a/src/components/home/FeaturedEvents.tsx b/src/components/home/FeaturedEvents.tsx index 36462ac..0efc2ba 100644 --- a/src/components/home/FeaturedEvents.tsx +++ b/src/components/home/FeaturedEvents.tsx @@ -1,27 +1,17 @@ -import Image from "next/image"; -import { ArrowUpLeft, CalendarDays, MapPin } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; -import { - Card, - CardContent, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Link } from "@/lib/router"; +import { ArrowUpLeft } from "lucide-react"; +import { FeaturedEventsCarousel } from "@/components/home/FeaturedEventsCarousel"; import ContentActionLink from "@/components/ContentActionLink"; import type { HomeContentPage } from "@/lib/content-cms"; import type { EventListItemSchema } from "@/lib/types"; -import { formatJalali, getEventCardImageUrl } from "@/lib/utils"; -import { apiBaseUrl, toAbsoluteUrl } from "@/lib/site"; type FeaturedEventsProps = { content: HomeContentPage; events: EventListItemSchema[]; - isArchive: boolean; }; -export default function FeaturedEvents({ content, events, isArchive }: FeaturedEventsProps) { +export default function FeaturedEvents({ content, events }: FeaturedEventsProps) { + const now = Date.now(); + return (
@@ -45,72 +35,7 @@ export default function FeaturedEvents({ content, events, isArchive }: FeaturedE
{events.length ? ( -
- {events.map((event) => { - const candidateImage = getEventCardImageUrl(event); - const hasImage = candidateImage !== "/placeholder.svg"; - const image = hasImage ? toAbsoluteUrl(candidateImage, apiBaseUrl) : undefined; - - return ( - -
- {image ? ( - {`پوستر - ) : ( - - )} - {isArchive ? ( - - از آرشیو - - ) : null} -
- - - - {event.title} - - - - -

- {event.description} -

-
- - - {(event.location || event.address || event.event_type_label) ? ( - - - ) : null} -
-
- - - {content.featured_event_action_label} - -
- ); - })} -
+ ) : (

در حال حاضر رویدادی برای نمایش نیست. diff --git a/src/components/home/FeaturedEventsCarousel.tsx b/src/components/home/FeaturedEventsCarousel.tsx new file mode 100644 index 0000000..519faa6 --- /dev/null +++ b/src/components/home/FeaturedEventsCarousel.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useCallback, useEffect, useRef } from "react"; +import type { MouseEvent as ReactMouseEvent } from "react"; +import { EventCard } from "@/components/events/EventCard"; +import type { EventListItemSchema } from "@/lib/types"; + +type FeaturedEventsCarouselProps = { + events: EventListItemSchema[]; + now: number; +}; + +export function FeaturedEventsCarousel({ events, now }: FeaturedEventsCarouselProps) { + const trackRef = useRef(null); + const cardRefs = useRef>([]); + const progressTrackRefs = useRef>([]); + const progressThumbRefs = useRef>([]); + const dragRef = useRef<{ startX: number; startY: number; startScrollLeft: number; moved: boolean; cancelled: boolean } | null>(null); + const suppressClickRef = useRef(false); + const hasMobileSlides = events.length > 1; + const hasTabletSlides = events.length > 2; + + const updateScrollProgress = useCallback(() => { + const track = trackRef.current; + if (!track) return; + + const maxScroll = Math.max(0, track.scrollWidth - track.clientWidth); + const firstCard = cardRefs.current[0]; + const trackRight = track.getBoundingClientRect().right; + const offset = firstCard ? Math.max(0, firstCard.getBoundingClientRect().right - trackRight) : 0; + const progress = maxScroll ? Math.min(1, offset / maxScroll) : 0; + const thumbWidth = 100 / events.length; + const thumbRight = progress * (100 - thumbWidth); + + progressThumbRefs.current.forEach((thumb) => { + if (!thumb) return; + thumb.style.width = `${thumbWidth}%`; + thumb.style.right = `${thumbRight}%`; + }); + progressTrackRefs.current.forEach((progressTrack) => { + progressTrack?.setAttribute("aria-valuenow", String(Math.round(progress * 100))); + }); + }, [events.length]); + + useEffect(() => { + updateScrollProgress(); + window.addEventListener("resize", updateScrollProgress); + return () => window.removeEventListener("resize", updateScrollProgress); + }, [updateScrollProgress]); + + useEffect(() => { + const handleMouseMove = (event: MouseEvent) => { + const drag = dragRef.current; + const track = trackRef.current; + if (!drag || !track || drag.cancelled) return; + + const deltaX = event.clientX - drag.startX; + const deltaY = event.clientY - drag.startY; + if (!drag.moved && Math.max(Math.abs(deltaX), Math.abs(deltaY)) < 6) return; + if (!drag.moved && Math.abs(deltaY) > Math.abs(deltaX)) { + drag.cancelled = true; + track.removeAttribute("data-dragging"); + return; + } + + drag.moved = true; + event.preventDefault(); + track.scrollLeft = drag.startScrollLeft - deltaX; + }; + + const handleMouseUp = () => { + const drag = dragRef.current; + if (!drag) return; + + dragRef.current = null; + trackRef.current?.removeAttribute("data-dragging"); + if (drag.moved || drag.cancelled) { + suppressClickRef.current = true; + window.setTimeout(() => { + suppressClickRef.current = false; + }, 0); + } + }; + + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, []); + + const startDragging = (event: ReactMouseEvent) => { + const isMobileSlider = hasMobileSlides && window.matchMedia("(max-width: 639px)").matches; + const isTabletSlider = hasTabletSlides && window.matchMedia("(min-width: 640px) and (max-width: 1279px)").matches; + if ((!isMobileSlider && !isTabletSlider) || event.button !== 0) return; + + dragRef.current = { + startX: event.clientX, + startY: event.clientY, + startScrollLeft: event.currentTarget.scrollLeft, + moved: false, + cancelled: false, + }; + event.currentTarget.dataset.dragging = "true"; + }; + + const handleClickCapture = (event: ReactMouseEvent) => { + if (!suppressClickRef.current) return; + event.preventDefault(); + event.stopPropagation(); + suppressClickRef.current = false; + }; + + const renderProgress = (index: number, className: string) => ( +

{ progressTrackRefs.current[index] = element; }} + className={`relative h-1.5 w-full overflow-hidden rounded-full bg-primary/10 ${className}`} + role="progressbar" + aria-label="موقعیت در رویدادهای ویژه" + aria-valuemin={0} + aria-valuemax={100} + aria-valuenow={0} + > + { progressThumbRefs.current[index] = element; }} + className="absolute inset-y-0 right-0 rounded-full bg-primary" + style={{ width: `${100 / events.length}%` }} + /> +
+ ); + + const trackClassName = `grid gap-3 sm:gap-4 xl:grid-cols-3 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${ + hasMobileSlides + ? "max-sm:flex max-sm:overflow-x-auto max-sm:touch-pan-x" + : "grid-cols-1" + } ${ + hasTabletSlides + ? "sm:max-xl:flex sm:max-xl:overflow-x-auto sm:max-xl:touch-pan-x" + : "sm:grid-cols-2" + }`; + + return ( + <> +
event.preventDefault()} + className={trackClassName} + > + {events.map((event, index) => ( +
{ cardRefs.current[index] = element; }} + className={`min-w-0 ${hasMobileSlides ? "max-sm:basis-[85%] max-sm:shrink-0" : ""} ${hasTabletSlides ? "sm:max-xl:basis-[calc(50%_-_0.5rem)] sm:max-xl:shrink-0" : ""}`} + > + +
+ ))} +
+ {hasMobileSlides ? renderProgress(0, "mt-5 sm:hidden") : null} + {hasTabletSlides ? renderProgress(1, "mt-5 hidden sm:block xl:hidden") : null} + + ); +} diff --git a/src/views/Home.tsx b/src/views/Home.tsx index be33e00..2014cea 100644 --- a/src/views/Home.tsx +++ b/src/views/Home.tsx @@ -12,10 +12,9 @@ type HomeProps = { siteContent: SiteContent; events: EventListItemSchema[]; posts: PostListSchema[]; - isEventArchive: boolean; }; -export default function Home({ content, siteContent, events, posts, isEventArchive }: HomeProps) { +export default function Home({ content, siteContent, events, posts }: HomeProps) { const heroImage = content.hero_image || siteContent.logo || { url: "/favicon.ico", alt: siteContent.short_name || "نشان انجمن", @@ -76,7 +75,7 @@ export default function Home({ content, siteContent, events, posts, isEventArchi
- +