F(frontend): add image lightbox and derivative fallbacks
This commit is contained in:
@@ -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