diff --git a/src/app/events/[slug]/page.tsx b/src/app/events/[slug]/page.tsx index b5cc7da..c69c85f 100644 --- a/src/app/events/[slug]/page.tsx +++ b/src/app/events/[slug]/page.tsx @@ -3,7 +3,7 @@ import { notFound } from "next/navigation"; import EventDetail from "@/views/EventDetail"; import { PublicApiError, getPublicEventBySlug } from "@/lib/public-api"; import { siteUrl } from "@/lib/site"; -import { getThumbUrl } from "@/lib/utils"; +import { getEventSeoImageUrl } from "@/lib/utils"; type Params = Promise<{ slug: string }>; @@ -31,7 +31,7 @@ export async function generateMetadata({ const { slug } = await params; const event = await loadEvent(slug); const description = cleanText(event.description).slice(0, 160); - const image = event.absolute_featured_image_url || getThumbUrl(event) || `${siteUrl}/favicon.ico`; + const image = getEventSeoImageUrl(event) || `${siteUrl}/favicon.ico`; return { title: event.title, @@ -84,7 +84,7 @@ export default async function EventDetailPage({ : event.event_type === "on_site" ? "https://schema.org/OfflineEventAttendanceMode" : "https://schema.org/MixedEventAttendanceMode", - image: [event.absolute_featured_image_url || getThumbUrl(event) || `${siteUrl}/favicon.ico`], + image: [getEventSeoImageUrl(event) || `${siteUrl}/favicon.ico`], url: `${siteUrl}/events/${event.slug}`, organizer: { "@type": "Organization", diff --git a/src/components/GalleryLightbox.tsx b/src/components/GalleryLightbox.tsx new file mode 100644 index 0000000..e81208c --- /dev/null +++ b/src/components/GalleryLightbox.tsx @@ -0,0 +1,175 @@ +"use client"; + +import * as React from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"; +import ProgressiveImage from "@/components/ProgressiveImage"; +import { Button } from "@/components/ui/button"; + +export type GalleryLightboxItem = { + id: number | string; + alt: string; + title?: string | null; + previewSrc?: string | null; + blurSrc?: string | null; + fullSrc?: string | null; +}; + +type GalleryLightboxProps = { + items: GalleryLightboxItem[]; + open: boolean; + initialIndex: number; + onOpenChange: (open: boolean) => void; +}; + +export default function GalleryLightbox({ + items, + open, + initialIndex, + onOpenChange, +}: GalleryLightboxProps) { + const [currentIndex, setCurrentIndex] = React.useState(initialIndex); + + React.useEffect(() => { + if (open) { + setCurrentIndex(initialIndex); + } + }, [initialIndex, open]); + + const currentItem = items[currentIndex]; + + const goToPrevious = React.useCallback(() => { + if (!items.length) { + return; + } + setCurrentIndex((current) => (current - 1 + items.length) % items.length); + }, [items.length]); + + const goToNext = React.useCallback(() => { + if (!items.length) { + return; + } + setCurrentIndex((current) => (current + 1) % items.length); + }, [items.length]); + + React.useEffect(() => { + if (!open) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "ArrowLeft") { + event.preventDefault(); + goToPrevious(); + return; + } + + if (event.key === "ArrowRight") { + event.preventDefault(); + goToNext(); + return; + } + + if (event.key === "Escape") { + onOpenChange(false); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [goToNext, goToPrevious, onOpenChange, open]); + + React.useEffect(() => { + if (!open || !items.length) { + return; + } + + const neighborIndexes = [ + (currentIndex - 1 + items.length) % items.length, + (currentIndex + 1) % items.length, + ]; + + neighborIndexes.forEach((index) => { + const src = items[index]?.fullSrc || items[index]?.previewSrc; + if (!src) { + return; + } + const image = new window.Image(); + image.src = src; + }); + }, [currentIndex, items, open]); + + if (!currentItem) { + return null; + } + + return ( + + + {currentItem.title || currentItem.alt} + + پیش‌نمایش تصویر {currentIndex + 1} از {items.length} + + +
+
+ + +
+ + +
+ +
+
+

{currentItem.title || currentItem.alt}

+
+

+ {currentIndex + 1} / {items.length} +

+
+ +
+
+ ); +} diff --git a/src/components/ProgressiveImage.tsx b/src/components/ProgressiveImage.tsx new file mode 100644 index 0000000..2b74c60 --- /dev/null +++ b/src/components/ProgressiveImage.tsx @@ -0,0 +1,72 @@ +"use client"; + +import * as React from "react"; +import { cn } from "@/lib/utils"; + +type ProgressiveImageProps = { + src?: string | null; + blurSrc?: string | null; + alt: string; + wrapperClassName?: string; + className?: string; + fallbackSrc?: string; + sizes?: string; + loading?: "eager" | "lazy"; + decoding?: "async" | "sync" | "auto"; + onClick?: React.MouseEventHandler; +}; + +const DEFAULT_FALLBACK = "/placeholder.svg"; + +export default function ProgressiveImage({ + src, + blurSrc, + alt, + wrapperClassName, + className, + fallbackSrc = DEFAULT_FALLBACK, + sizes, + loading = "lazy", + decoding = "async", + onClick, +}: ProgressiveImageProps) { + const resolvedSrc = src || blurSrc || fallbackSrc; + const [loaded, setLoaded] = React.useState(false); + + React.useEffect(() => { + setLoaded(false); + }, [resolvedSrc]); + + return ( +
+ {!loaded && ( + <> + {blurSrc ? ( + + ) : null} + + ); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index a255b02..9bffb0f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -26,6 +26,8 @@ export interface UserProfileSchema { first_name: string; last_name: string; profile_picture?: string; + profile_picture_thumbnail_url?: string | null; + profile_picture_preview_url?: string | null; bio?: string; student_id?: string | null; year_of_study?: number; @@ -110,12 +112,16 @@ export interface PostListSchema { excerpt?: string; featured_image?: string; absolute_featured_image_url?: string | null; + absolute_featured_image_thumbnail_url?: string | null; + absolute_featured_image_preview_url?: string | null; author: { id: number; username: string; first_name: string; last_name: string; profile_picture?: string; + profile_picture_thumbnail_url?: string | null; + profile_picture_preview_url?: string | null; }; category?: { id: number; @@ -198,6 +204,8 @@ export interface EventListItemSchema { description: string; featured_image?: string | null; absolute_featured_image_url?: string | null; + absolute_featured_image_thumbnail_url?: string | null; + absolute_featured_image_preview_url?: string | null; event_type: 'online' | 'on_site' | 'hybrid'; address?: string | null; location?: string | null; @@ -220,6 +228,8 @@ export interface EventGalleryItem { title: string; description: string; absolute_image_url?: string | null; + absolute_image_preview_url?: string | null; + absolute_image_blur_url?: string | null; width?: number; height?: number; } @@ -328,6 +338,11 @@ export interface GalleryImageSchema { title: string; description?: string; image: string; + absolute_image_url?: string | null; + absolute_image_preview_url?: string | null; + absolute_image_blur_url?: string | null; + width?: number | null; + height?: number | null; uploaded_by: { id: number; username: string; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 4d74499..cc3a59f 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -35,11 +35,71 @@ export function formatJalali(iso?: string, withTime: boolean = true): string { } } -const DEFAULT_THUMB = '/placeholder.svg'; -export const getThumbUrl = (e: Types.EventListItemSchema) => - e.absolute_featured_image_url || - e.featured_image || - DEFAULT_THUMB; +const DEFAULT_THUMB = "/placeholder.svg"; + +const pickFirstUrl = (...values: Array) => + values.find((value) => Boolean(value)) || DEFAULT_THUMB; + +export const getEventCardImageUrl = (event: Types.EventListItemSchema) => + pickFirstUrl( + event.absolute_featured_image_thumbnail_url, + event.absolute_featured_image_preview_url, + event.absolute_featured_image_url, + event.featured_image, + ); + +export const getEventHeroImageUrl = (event: Types.EventListItemSchema) => + pickFirstUrl( + event.absolute_featured_image_preview_url, + event.absolute_featured_image_url, + event.absolute_featured_image_thumbnail_url, + event.featured_image, + ); + +export const getEventSeoImageUrl = (event: Types.EventListItemSchema) => + pickFirstUrl( + event.absolute_featured_image_url, + event.absolute_featured_image_preview_url, + event.absolute_featured_image_thumbnail_url, + event.featured_image, + ); + +export const getThumbUrl = getEventCardImageUrl; + +export const getGalleryImagePreviewUrl = ( + image: + | Types.EventGalleryItem + | Types.GalleryImageSchema, +) => + pickFirstUrl( + image.absolute_image_preview_url, + image.absolute_image_url, + "image" in image ? image.image : undefined, + ); + +export const getGalleryImageBlurUrl = ( + image: + | Types.EventGalleryItem + | Types.GalleryImageSchema, +) => + pickFirstUrl( + image.absolute_image_blur_url, + image.absolute_image_preview_url, + image.absolute_image_url, + "image" in image ? image.image : undefined, + ); + +export const getGalleryImageFullUrl = ( + image: + | Types.EventGalleryItem + | Types.GalleryImageSchema, +) => + pickFirstUrl( + image.absolute_image_url, + image.absolute_image_preview_url, + image.absolute_image_blur_url, + "image" in image ? image.image : undefined, + ); const PERSIAN_DIGITS = ['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹']; diff --git a/src/views/AdminEvents.tsx b/src/views/AdminEvents.tsx index ada58b3..3a598db 100644 --- a/src/views/AdminEvents.tsx +++ b/src/views/AdminEvents.tsx @@ -11,8 +11,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { ScrollArea } from '@/components/ui/scroll-area'; +import ProgressiveImage from '@/components/ProgressiveImage'; import { useToast } from '@/hooks/use-toast'; -import { formatJalali, formatToman, getThumbUrl, resolveErrorMessage, toPersianDigits } from '@/lib/utils'; +import { formatJalali, formatToman, getEventCardImageUrl, resolveErrorMessage, toPersianDigits } from '@/lib/utils'; const EVENTS_PAGE_SIZE = 30; @@ -225,11 +226,11 @@ const AdminEventsPage: React.FC = () => { {sortedEvents.map((event) => ( - {event.title} navigate(`/admin/events/${event.id}`)}> diff --git a/src/views/EventDetail.tsx b/src/views/EventDetail.tsx index be19a93..dfcc819 100644 --- a/src/views/EventDetail.tsx +++ b/src/views/EventDetail.tsx @@ -8,6 +8,8 @@ import type * as Types from "@/lib/types"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import GalleryLightbox, { type GalleryLightboxItem } from "@/components/GalleryLightbox"; +import ProgressiveImage from "@/components/ProgressiveImage"; import { useToast } from "@/hooks/use-toast"; import Markdown from "@/components/Markdown"; import CouponDialogFa from "@/components/CouponDialogFa"; @@ -15,7 +17,12 @@ import { formatJalali, formatNumberPersian, formatToman, - getThumbUrl, + getEventCardImageUrl, + getEventHeroImageUrl, + getEventSeoImageUrl, + getGalleryImageBlurUrl, + getGalleryImageFullUrl, + getGalleryImagePreviewUrl, resolveErrorMessage, toPersianDigits, } from "@/lib/utils"; @@ -62,8 +69,9 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) { const [event, setEvent] = useState(initialEvent); const [eventThumb, setEventThumb] = useState( - initialEvent ? getThumbUrl(initialEvent) : null, + initialEvent ? getEventCardImageUrl(initialEvent) : null, ); + const [lightboxIndex, setLightboxIndex] = useState(null); const [loading, setLoading] = useState(!initialEvent); const [open, setOpen] = useState(false); const [submitting, setSubmitting] = useState(false); @@ -94,7 +102,7 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) { const canonicalUrl = event ? `${siteUrl}/events/${event.slug}` : `${siteUrl}/events`; const primaryImage = event - ? toAbsoluteUrl(getThumbUrl(event)) ?? `${siteUrl}/favicon.ico` + ? toAbsoluteUrl(getEventSeoImageUrl(event)) ?? `${siteUrl}/favicon.ico` : `${siteUrl}/favicon.ico`; const pageTitle = event ? `${event.title} | ${siteName}` : `جزئیات رویداد | ${siteName}`; const pageDescription = sanitizeDescription(event?.description); @@ -257,14 +265,14 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) { if (initialEvent && initialEvent.slug === slug) { setEvent(initialEvent); - setEventThumb(getThumbUrl(initialEvent)); + setEventThumb(getEventCardImageUrl(initialEvent)); return; } const data = await api.getEventBySlug(slug); if (cancelled) return; setEvent(data); - setEventThumb(getThumbUrl(data)); + setEventThumb(getEventCardImageUrl(data)); } catch (error: unknown) { if (cancelled) return; toast({ @@ -335,6 +343,19 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) { return { registrationOpen, remaining, full }; }, [deadlineTs, event, nowTs, rsTs]); + const galleryItems = useMemo( + () => + (event?.gallery_images ?? []).map((image) => ({ + id: image.id, + alt: image.title || event?.title || "تصویر رویداد", + title: image.title, + previewSrc: getGalleryImagePreviewUrl(image), + blurSrc: getGalleryImageBlurUrl(image), + fullSrc: getGalleryImageFullUrl(image), + })), + [event], + ); + const eventStructuredData = useMemo(() => { if (!event) return null; @@ -501,12 +522,12 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
- {event.title}
@@ -529,17 +550,25 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
- {event.gallery_images?.length ? ( + {galleryItems.length ? (

گالری تصاویر

- {event.gallery_images.map((image) => ( - ( + ))}
@@ -608,6 +637,17 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
+ + { + if (!isOpen) { + setLightboxIndex(null); + } + }} + /> ); } diff --git a/src/views/Events.tsx b/src/views/Events.tsx index 82a8b7c..d01ec4e 100644 --- a/src/views/Events.tsx +++ b/src/views/Events.tsx @@ -8,8 +8,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; +import ProgressiveImage from "@/components/ProgressiveImage"; import type * as Types from "@/lib/types"; -import { formatJalali, formatNumberPersian, formatToman, getThumbUrl } from "@/lib/utils"; +import { formatJalali, formatNumberPersian, formatToman, getEventCardImageUrl } from "@/lib/utils"; import { siteUrl } from "@/lib/site"; type EventsProps = { @@ -73,7 +74,7 @@ export default function Events({ const ogImage = useMemo(() => { if (!events.length) return `${siteUrl}/favicon.ico`; - return toAbsoluteUrl(getThumbUrl(events[0])) ?? `${siteUrl}/favicon.ico`; + return toAbsoluteUrl(getEventCardImageUrl(events[0])) ?? `${siteUrl}/favicon.ico`; }, [events]); const listStructuredData = useMemo(() => { @@ -93,7 +94,7 @@ export default function Events({ listItem.endDate = eventItem.end_time; } - const imageUrl = toAbsoluteUrl(getThumbUrl(eventItem)); + const imageUrl = toAbsoluteUrl(getEventCardImageUrl(eventItem)); if (imageUrl) { listItem.image = imageUrl; } @@ -210,12 +211,11 @@ export default function Events({
- {event.title}