F(frontend): add image lightbox and derivative fallbacks
This commit is contained in:
@@ -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",
|
||||
|
||||
175
src/components/GalleryLightbox.tsx
Normal file
175
src/components/GalleryLightbox.tsx
Normal file
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="w-[min(96vw,1100px)] max-w-none overflow-hidden border-none bg-transparent p-0 shadow-none"
|
||||
dir="rtl"
|
||||
>
|
||||
<DialogTitle className="sr-only">{currentItem.title || currentItem.alt}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
پیشنمایش تصویر {currentIndex + 1} از {items.length}
|
||||
</DialogDescription>
|
||||
|
||||
<div className="relative overflow-hidden rounded-2xl border border-white/10 bg-black/90 text-white shadow-2xl">
|
||||
<div className="relative min-h-[70vh]">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="تصویر قبلی"
|
||||
className="absolute inset-y-0 left-0 z-10 w-1/4 cursor-w-resize bg-transparent"
|
||||
onClick={goToPrevious}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="تصویر بعدی"
|
||||
className="absolute inset-y-0 right-0 z-10 w-1/4 cursor-e-resize bg-transparent"
|
||||
onClick={goToNext}
|
||||
/>
|
||||
|
||||
<div className="absolute left-4 top-4 z-20 flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
className="border-white/10 bg-black/45 text-white hover:bg-black/70"
|
||||
onClick={goToNext}
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
className="border-white/10 bg-black/45 text-white hover:bg-black/70"
|
||||
onClick={goToPrevious}
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ProgressiveImage
|
||||
src={currentItem.fullSrc || currentItem.previewSrc}
|
||||
blurSrc={currentItem.blurSrc || currentItem.previewSrc}
|
||||
alt={currentItem.alt}
|
||||
loading="eager"
|
||||
wrapperClassName="min-h-[70vh]"
|
||||
className="max-h-[80vh] w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 border-t border-white/10 bg-black/75 px-5 py-4 text-sm">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{currentItem.title || currentItem.alt}</p>
|
||||
</div>
|
||||
<p className="shrink-0 text-white/70">
|
||||
{currentIndex + 1} / {items.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
72
src/components/ProgressiveImage.tsx
Normal file
72
src/components/ProgressiveImage.tsx
Normal file
@@ -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<HTMLImageElement>;
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className={cn("relative overflow-hidden bg-muted", wrapperClassName)}>
|
||||
{!loaded && (
|
||||
<>
|
||||
{blurSrc ? (
|
||||
<img
|
||||
src={blurSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 h-full w-full scale-110 object-cover blur-2xl"
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 animate-pulse bg-muted/80" aria-hidden="true" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<img
|
||||
src={resolvedSrc}
|
||||
alt={alt}
|
||||
sizes={sizes}
|
||||
loading={loading}
|
||||
decoding={decoding}
|
||||
onClick={onClick}
|
||||
onLoad={() => setLoaded(true)}
|
||||
className={cn(
|
||||
"h-full w-full object-cover transition-opacity duration-300",
|
||||
loaded ? "opacity-100" : "opacity-0",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string | null | undefined>) =>
|
||||
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 = ['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹'];
|
||||
|
||||
|
||||
@@ -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) => (
|
||||
<tr key={event.id} className="border-b last:border-0 hover:bg-muted/50">
|
||||
<td className="px-3 py-2 text-right">
|
||||
<img
|
||||
src={getThumbUrl(event)}
|
||||
<ProgressiveImage
|
||||
src={getEventCardImageUrl(event)}
|
||||
alt={event.title}
|
||||
wrapperClassName="h-12 w-12 rounded"
|
||||
className="h-12 w-12 rounded object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right cursor-pointer" onClick={() => navigate(`/admin/events/${event.id}`)}>
|
||||
|
||||
@@ -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<Types.EventDetailSchema | null>(initialEvent);
|
||||
const [eventThumb, setEventThumb] = useState<string | null>(
|
||||
initialEvent ? getThumbUrl(initialEvent) : null,
|
||||
initialEvent ? getEventCardImageUrl(initialEvent) : null,
|
||||
);
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(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<GalleryLightboxItem[]>(
|
||||
() =>
|
||||
(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) {
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="overflow-hidden">
|
||||
<div className="w-full aspect-video overflow-hidden rounded-lg">
|
||||
<img
|
||||
src={getThumbUrl(event)}
|
||||
<ProgressiveImage
|
||||
src={getEventHeroImageUrl(event)}
|
||||
blurSrc={event.absolute_featured_image_thumbnail_url}
|
||||
alt={event.title}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
wrapperClassName="h-full w-full"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -529,17 +550,25 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{event.gallery_images?.length ? (
|
||||
{galleryItems.length ? (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-semibold mb-3">گالری تصاویر</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{event.gallery_images.map((image) => (
|
||||
<img
|
||||
{galleryItems.map((image, index) => (
|
||||
<button
|
||||
key={image.id}
|
||||
src={image.absolute_image_url || ""}
|
||||
alt={image.title || ""}
|
||||
className="w-full h-36 object-cover rounded-md"
|
||||
type="button"
|
||||
className="overflow-hidden rounded-md text-right transition-transform hover:scale-[1.01] focus:outline-none focus:ring-2 focus:ring-primary/40"
|
||||
onClick={() => setLightboxIndex(index)}
|
||||
>
|
||||
<ProgressiveImage
|
||||
src={image.previewSrc}
|
||||
blurSrc={image.blurSrc}
|
||||
alt={image.alt}
|
||||
wrapperClassName="h-36 w-full"
|
||||
className="h-36 w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -608,6 +637,17 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GalleryLightbox
|
||||
items={galleryItems}
|
||||
open={lightboxIndex !== null}
|
||||
initialIndex={lightboxIndex ?? 0}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setLightboxIndex(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<Link key={event.id} to={`/events/${event.slug}`} className="block h-full">
|
||||
<Card className="h-full flex flex-col hover:shadow-lg transition-shadow">
|
||||
<div className="w-full aspect-video overflow-hidden rounded-lg">
|
||||
<img
|
||||
src={getThumbUrl(event)}
|
||||
<ProgressiveImage
|
||||
src={getEventCardImageUrl(event)}
|
||||
alt={event.title}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
wrapperClassName="h-full w-full"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user