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

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

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

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

View File

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

View File

@@ -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 = ['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹'];

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>