feat: add responsive featured events carousel

This commit is contained in:
2026-10-02 14:11:31 +03:30
parent 7da500ed97
commit 44045120b1
4 changed files with 178 additions and 85 deletions

View File

@@ -51,7 +51,6 @@ export default async function HomePage() {
siteContent={siteContent}
events={selectedEvents.events}
posts={posts}
isEventArchive={selectedEvents.isArchive}
/>
</>
);

View File

@@ -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 (
<section className="py-14 md:py-20" aria-labelledby="home-events-title">
<div className="container mx-auto px-4">
@@ -45,72 +35,7 @@ export default function FeaturedEvents({ content, events, isArchive }: FeaturedE
</div>
{events.length ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{events.map((event) => {
const candidateImage = getEventCardImageUrl(event);
const hasImage = candidateImage !== "/placeholder.svg";
const image = hasImage ? toAbsoluteUrl(candidateImage, apiBaseUrl) : undefined;
return (
<Card key={event.id} className="overflow-hidden rounded-2xl border-border/80 shadow-none transition-colors hover:border-primary/40">
<div className="relative aspect-[16/10] bg-brand-wash">
{image ? (
<Image
src={image}
alt={`پوستر ${event.title}`}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
unoptimized
className="object-contain p-2"
/>
) : (
<div className="grid h-full place-items-center text-brand-deep" aria-hidden="true">
<CalendarDays className="h-10 w-10" strokeWidth={1.5} />
</div>
)}
{isArchive ? (
<Badge variant="secondary" className="absolute right-3 top-3 border border-border/70 bg-background/95 text-foreground">
از آرشیو
</Badge>
) : null}
</div>
<CardHeader className="space-y-2 p-4 pb-2">
<CardTitle className="text-lg leading-7">
<Link to={`/events/${encodeURIComponent(event.slug)}`} className="rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
{event.title}
</Link>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 p-4 pt-1">
<p className="line-clamp-2 min-h-12 text-sm leading-6 text-muted-foreground">
{event.description}
</p>
<div className="flex flex-wrap gap-x-4 gap-y-2 border-t border-border/70 pt-3 text-xs leading-5 text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<CalendarDays aria-hidden="true" className="h-4 w-4 shrink-0 text-primary" />
<time dateTime={event.start_time}>{formatJalali(event.start_time, false)}</time>
</span>
{(event.location || event.address || event.event_type_label) ? (
<span className="inline-flex items-center gap-1.5">
<MapPin aria-hidden="true" className="h-4 w-4 shrink-0 text-primary" />
<span>{event.location || event.address || event.event_type_label}</span>
</span>
) : null}
</div>
</CardContent>
<CardFooter className="px-4 pb-4 pt-0">
<Link
to={`/events/${encodeURIComponent(event.slug)}`}
className="inline-flex min-h-10 items-center gap-2 rounded-lg px-2 text-sm font-semibold text-primary underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{content.featured_event_action_label}
<ArrowUpLeft aria-hidden="true" className="h-4 w-4" />
</Link>
</CardFooter>
</Card>
);
})}
</div>
<FeaturedEventsCarousel events={events} now={now} />
) : (
<p role="status" className="rounded-xl border border-dashed border-border px-5 py-8 text-center text-sm text-muted-foreground">
در حال حاضر رویدادی برای نمایش نیست.

View File

@@ -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<HTMLDivElement>(null);
const cardRefs = useRef<Array<HTMLDivElement | null>>([]);
const progressTrackRefs = useRef<Array<HTMLDivElement | null>>([]);
const progressThumbRefs = useRef<Array<HTMLSpanElement | null>>([]);
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
if (!suppressClickRef.current) return;
event.preventDefault();
event.stopPropagation();
suppressClickRef.current = false;
};
const renderProgress = (index: number, className: string) => (
<div
ref={(element) => { 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}
>
<span
ref={(element) => { progressThumbRefs.current[index] = element; }}
className="absolute inset-y-0 right-0 rounded-full bg-primary"
style={{ width: `${100 / events.length}%` }}
/>
</div>
);
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 (
<>
<div
ref={trackRef}
role="region"
aria-label="رویدادهای ویژه"
tabIndex={0}
onScroll={updateScrollProgress}
onMouseDown={startDragging}
onClickCapture={handleClickCapture}
onDragStart={(event) => event.preventDefault()}
className={trackClassName}
>
{events.map((event, index) => (
<div
key={event.id}
ref={(element) => { 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" : ""}`}
>
<EventCard event={event} now={now} />
</div>
))}
</div>
{hasMobileSlides ? renderProgress(0, "mt-5 sm:hidden") : null}
{hasTabletSlides ? renderProgress(1, "mt-5 hidden sm:block xl:hidden") : null}
</>
);
}

View File

@@ -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
</div>
</section>
<FeaturedEvents content={content} events={events} isArchive={isEventArchive} />
<FeaturedEvents content={content} events={events} />
<FeaturedPosts content={content} siteContent={siteContent} posts={posts} />
<section className="bg-primary text-primary-foreground" aria-labelledby="home-participation-title">