F(frontend): add image lightbox and derivative fallbacks
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled

This commit is contained in:
2026-05-20 14:26:49 +03:30
parent 5711961b9b
commit 18de81c173
8 changed files with 400 additions and 37 deletions

View File

@@ -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}`)}>

View File

@@ -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>
);
}

View File

@@ -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>