feat: add responsive featured events carousel
This commit is contained in:
@@ -51,7 +51,6 @@ export default async function HomePage() {
|
|||||||
siteContent={siteContent}
|
siteContent={siteContent}
|
||||||
events={selectedEvents.events}
|
events={selectedEvents.events}
|
||||||
posts={posts}
|
posts={posts}
|
||||||
isEventArchive={selectedEvents.isArchive}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,27 +1,17 @@
|
|||||||
import Image from "next/image";
|
import { ArrowUpLeft } from "lucide-react";
|
||||||
import { ArrowUpLeft, CalendarDays, MapPin } from "lucide-react";
|
import { FeaturedEventsCarousel } from "@/components/home/FeaturedEventsCarousel";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Link } from "@/lib/router";
|
|
||||||
import ContentActionLink from "@/components/ContentActionLink";
|
import ContentActionLink from "@/components/ContentActionLink";
|
||||||
import type { HomeContentPage } from "@/lib/content-cms";
|
import type { HomeContentPage } from "@/lib/content-cms";
|
||||||
import type { EventListItemSchema } from "@/lib/types";
|
import type { EventListItemSchema } from "@/lib/types";
|
||||||
import { formatJalali, getEventCardImageUrl } from "@/lib/utils";
|
|
||||||
import { apiBaseUrl, toAbsoluteUrl } from "@/lib/site";
|
|
||||||
|
|
||||||
type FeaturedEventsProps = {
|
type FeaturedEventsProps = {
|
||||||
content: HomeContentPage;
|
content: HomeContentPage;
|
||||||
events: EventListItemSchema[];
|
events: EventListItemSchema[];
|
||||||
isArchive: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function FeaturedEvents({ content, events, isArchive }: FeaturedEventsProps) {
|
export default function FeaturedEvents({ content, events }: FeaturedEventsProps) {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="py-14 md:py-20" aria-labelledby="home-events-title">
|
<section className="py-14 md:py-20" aria-labelledby="home-events-title">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
@@ -45,72 +35,7 @@ export default function FeaturedEvents({ content, events, isArchive }: FeaturedE
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{events.length ? (
|
{events.length ? (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<FeaturedEventsCarousel events={events} now={now} />
|
||||||
{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>
|
|
||||||
) : (
|
) : (
|
||||||
<p role="status" className="rounded-xl border border-dashed border-border px-5 py-8 text-center text-sm text-muted-foreground">
|
<p role="status" className="rounded-xl border border-dashed border-border px-5 py-8 text-center text-sm text-muted-foreground">
|
||||||
در حال حاضر رویدادی برای نمایش نیست.
|
در حال حاضر رویدادی برای نمایش نیست.
|
||||||
|
|||||||
170
src/components/home/FeaturedEventsCarousel.tsx
Normal file
170
src/components/home/FeaturedEventsCarousel.tsx
Normal 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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,10 +12,9 @@ type HomeProps = {
|
|||||||
siteContent: SiteContent;
|
siteContent: SiteContent;
|
||||||
events: EventListItemSchema[];
|
events: EventListItemSchema[];
|
||||||
posts: PostListSchema[];
|
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 || {
|
const heroImage = content.hero_image || siteContent.logo || {
|
||||||
url: "/favicon.ico",
|
url: "/favicon.ico",
|
||||||
alt: siteContent.short_name || "نشان انجمن",
|
alt: siteContent.short_name || "نشان انجمن",
|
||||||
@@ -76,7 +75,7 @@ export default function Home({ content, siteContent, events, posts, isEventArchi
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<FeaturedEvents content={content} events={events} isArchive={isEventArchive} />
|
<FeaturedEvents content={content} events={events} />
|
||||||
<FeaturedPosts content={content} siteContent={siteContent} posts={posts} />
|
<FeaturedPosts content={content} siteContent={siteContent} posts={posts} />
|
||||||
|
|
||||||
<section className="bg-primary text-primary-foreground" aria-labelledby="home-participation-title">
|
<section className="bg-primary text-primary-foreground" aria-labelledby="home-participation-title">
|
||||||
|
|||||||
Reference in New Issue
Block a user