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 EventDetail from "@/views/EventDetail";
|
||||||
import { PublicApiError, getPublicEventBySlug } from "@/lib/public-api";
|
import { PublicApiError, getPublicEventBySlug } from "@/lib/public-api";
|
||||||
import { siteUrl } from "@/lib/site";
|
import { siteUrl } from "@/lib/site";
|
||||||
import { getThumbUrl } from "@/lib/utils";
|
import { getEventSeoImageUrl } from "@/lib/utils";
|
||||||
|
|
||||||
type Params = Promise<{ slug: string }>;
|
type Params = Promise<{ slug: string }>;
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ export async function generateMetadata({
|
|||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const event = await loadEvent(slug);
|
const event = await loadEvent(slug);
|
||||||
const description = cleanText(event.description).slice(0, 160);
|
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 {
|
return {
|
||||||
title: event.title,
|
title: event.title,
|
||||||
@@ -84,7 +84,7 @@ export default async function EventDetailPage({
|
|||||||
: event.event_type === "on_site"
|
: event.event_type === "on_site"
|
||||||
? "https://schema.org/OfflineEventAttendanceMode"
|
? "https://schema.org/OfflineEventAttendanceMode"
|
||||||
: "https://schema.org/MixedEventAttendanceMode",
|
: "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}`,
|
url: `${siteUrl}/events/${event.slug}`,
|
||||||
organizer: {
|
organizer: {
|
||||||
"@type": "Organization",
|
"@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;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
profile_picture?: string;
|
profile_picture?: string;
|
||||||
|
profile_picture_thumbnail_url?: string | null;
|
||||||
|
profile_picture_preview_url?: string | null;
|
||||||
bio?: string;
|
bio?: string;
|
||||||
student_id?: string | null;
|
student_id?: string | null;
|
||||||
year_of_study?: number;
|
year_of_study?: number;
|
||||||
@@ -110,12 +112,16 @@ export interface PostListSchema {
|
|||||||
excerpt?: string;
|
excerpt?: string;
|
||||||
featured_image?: string;
|
featured_image?: string;
|
||||||
absolute_featured_image_url?: string | null;
|
absolute_featured_image_url?: string | null;
|
||||||
|
absolute_featured_image_thumbnail_url?: string | null;
|
||||||
|
absolute_featured_image_preview_url?: string | null;
|
||||||
author: {
|
author: {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
profile_picture?: string;
|
profile_picture?: string;
|
||||||
|
profile_picture_thumbnail_url?: string | null;
|
||||||
|
profile_picture_preview_url?: string | null;
|
||||||
};
|
};
|
||||||
category?: {
|
category?: {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -198,6 +204,8 @@ export interface EventListItemSchema {
|
|||||||
description: string;
|
description: string;
|
||||||
featured_image?: string | null;
|
featured_image?: string | null;
|
||||||
absolute_featured_image_url?: 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';
|
event_type: 'online' | 'on_site' | 'hybrid';
|
||||||
address?: string | null;
|
address?: string | null;
|
||||||
location?: string | null;
|
location?: string | null;
|
||||||
@@ -220,6 +228,8 @@ export interface EventGalleryItem {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
absolute_image_url?: string | null;
|
absolute_image_url?: string | null;
|
||||||
|
absolute_image_preview_url?: string | null;
|
||||||
|
absolute_image_blur_url?: string | null;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
}
|
}
|
||||||
@@ -328,6 +338,11 @@ export interface GalleryImageSchema {
|
|||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
image: 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: {
|
uploaded_by: {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
|
|||||||
@@ -35,11 +35,71 @@ export function formatJalali(iso?: string, withTime: boolean = true): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_THUMB = '/placeholder.svg';
|
const DEFAULT_THUMB = "/placeholder.svg";
|
||||||
export const getThumbUrl = (e: Types.EventListItemSchema) =>
|
|
||||||
e.absolute_featured_image_url ||
|
const pickFirstUrl = (...values: Array<string | null | undefined>) =>
|
||||||
e.featured_image ||
|
values.find((value) => Boolean(value)) || DEFAULT_THUMB;
|
||||||
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 = ['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹'];
|
const PERSIAN_DIGITS = ['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹'];
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import ProgressiveImage from '@/components/ProgressiveImage';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
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;
|
const EVENTS_PAGE_SIZE = 30;
|
||||||
|
|
||||||
@@ -225,11 +226,11 @@ const AdminEventsPage: React.FC = () => {
|
|||||||
{sortedEvents.map((event) => (
|
{sortedEvents.map((event) => (
|
||||||
<tr key={event.id} className="border-b last:border-0 hover:bg-muted/50">
|
<tr key={event.id} className="border-b last:border-0 hover:bg-muted/50">
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right">
|
||||||
<img
|
<ProgressiveImage
|
||||||
src={getThumbUrl(event)}
|
src={getEventCardImageUrl(event)}
|
||||||
alt={event.title}
|
alt={event.title}
|
||||||
|
wrapperClassName="h-12 w-12 rounded"
|
||||||
className="h-12 w-12 rounded object-cover"
|
className="h-12 w-12 rounded object-cover"
|
||||||
loading="lazy"
|
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right cursor-pointer" onClick={() => navigate(`/admin/events/${event.id}`)}>
|
<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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { useToast } from "@/hooks/use-toast";
|
||||||
import Markdown from "@/components/Markdown";
|
import Markdown from "@/components/Markdown";
|
||||||
import CouponDialogFa from "@/components/CouponDialogFa";
|
import CouponDialogFa from "@/components/CouponDialogFa";
|
||||||
@@ -15,7 +17,12 @@ import {
|
|||||||
formatJalali,
|
formatJalali,
|
||||||
formatNumberPersian,
|
formatNumberPersian,
|
||||||
formatToman,
|
formatToman,
|
||||||
getThumbUrl,
|
getEventCardImageUrl,
|
||||||
|
getEventHeroImageUrl,
|
||||||
|
getEventSeoImageUrl,
|
||||||
|
getGalleryImageBlurUrl,
|
||||||
|
getGalleryImageFullUrl,
|
||||||
|
getGalleryImagePreviewUrl,
|
||||||
resolveErrorMessage,
|
resolveErrorMessage,
|
||||||
toPersianDigits,
|
toPersianDigits,
|
||||||
} from "@/lib/utils";
|
} from "@/lib/utils";
|
||||||
@@ -62,8 +69,9 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
|
|||||||
|
|
||||||
const [event, setEvent] = useState<Types.EventDetailSchema | null>(initialEvent);
|
const [event, setEvent] = useState<Types.EventDetailSchema | null>(initialEvent);
|
||||||
const [eventThumb, setEventThumb] = useState<string | null>(
|
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 [loading, setLoading] = useState(!initialEvent);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [submitting, setSubmitting] = 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 canonicalUrl = event ? `${siteUrl}/events/${event.slug}` : `${siteUrl}/events`;
|
||||||
const primaryImage = event
|
const primaryImage = event
|
||||||
? toAbsoluteUrl(getThumbUrl(event)) ?? `${siteUrl}/favicon.ico`
|
? toAbsoluteUrl(getEventSeoImageUrl(event)) ?? `${siteUrl}/favicon.ico`
|
||||||
: `${siteUrl}/favicon.ico`;
|
: `${siteUrl}/favicon.ico`;
|
||||||
const pageTitle = event ? `${event.title} | ${siteName}` : `جزئیات رویداد | ${siteName}`;
|
const pageTitle = event ? `${event.title} | ${siteName}` : `جزئیات رویداد | ${siteName}`;
|
||||||
const pageDescription = sanitizeDescription(event?.description);
|
const pageDescription = sanitizeDescription(event?.description);
|
||||||
@@ -257,14 +265,14 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
|
|||||||
|
|
||||||
if (initialEvent && initialEvent.slug === slug) {
|
if (initialEvent && initialEvent.slug === slug) {
|
||||||
setEvent(initialEvent);
|
setEvent(initialEvent);
|
||||||
setEventThumb(getThumbUrl(initialEvent));
|
setEventThumb(getEventCardImageUrl(initialEvent));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await api.getEventBySlug(slug);
|
const data = await api.getEventBySlug(slug);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setEvent(data);
|
setEvent(data);
|
||||||
setEventThumb(getThumbUrl(data));
|
setEventThumb(getEventCardImageUrl(data));
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
toast({
|
toast({
|
||||||
@@ -335,6 +343,19 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
|
|||||||
return { registrationOpen, remaining, full };
|
return { registrationOpen, remaining, full };
|
||||||
}, [deadlineTs, event, nowTs, rsTs]);
|
}, [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(() => {
|
const eventStructuredData = useMemo(() => {
|
||||||
if (!event) return null;
|
if (!event) return null;
|
||||||
|
|
||||||
@@ -501,12 +522,12 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
|
|||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<div className="w-full aspect-video overflow-hidden rounded-lg">
|
<div className="w-full aspect-video overflow-hidden rounded-lg">
|
||||||
<img
|
<ProgressiveImage
|
||||||
src={getThumbUrl(event)}
|
src={getEventHeroImageUrl(event)}
|
||||||
|
blurSrc={event.absolute_featured_image_thumbnail_url}
|
||||||
alt={event.title}
|
alt={event.title}
|
||||||
className="w-full h-full object-cover"
|
wrapperClassName="h-full w-full"
|
||||||
loading="lazy"
|
className="h-full w-full object-cover"
|
||||||
decoding="async"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -529,17 +550,25 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{event.gallery_images?.length ? (
|
{galleryItems.length ? (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<h3 className="text-lg font-semibold mb-3">گالری تصاویر</h3>
|
<h3 className="text-lg font-semibold mb-3">گالری تصاویر</h3>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||||
{event.gallery_images.map((image) => (
|
{galleryItems.map((image, index) => (
|
||||||
<img
|
<button
|
||||||
key={image.id}
|
key={image.id}
|
||||||
src={image.absolute_image_url || ""}
|
type="button"
|
||||||
alt={image.title || ""}
|
className="overflow-hidden rounded-md text-right transition-transform hover:scale-[1.01] focus:outline-none focus:ring-2 focus:ring-primary/40"
|
||||||
className="w-full h-36 object-cover rounded-md"
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -608,6 +637,17 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<GalleryLightbox
|
||||||
|
items={galleryItems}
|
||||||
|
open={lightboxIndex !== null}
|
||||||
|
initialIndex={lightboxIndex ?? 0}
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setLightboxIndex(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import ProgressiveImage from "@/components/ProgressiveImage";
|
||||||
import type * as Types from "@/lib/types";
|
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";
|
import { siteUrl } from "@/lib/site";
|
||||||
|
|
||||||
type EventsProps = {
|
type EventsProps = {
|
||||||
@@ -73,7 +74,7 @@ export default function Events({
|
|||||||
|
|
||||||
const ogImage = useMemo(() => {
|
const ogImage = useMemo(() => {
|
||||||
if (!events.length) return `${siteUrl}/favicon.ico`;
|
if (!events.length) return `${siteUrl}/favicon.ico`;
|
||||||
return toAbsoluteUrl(getThumbUrl(events[0])) ?? `${siteUrl}/favicon.ico`;
|
return toAbsoluteUrl(getEventCardImageUrl(events[0])) ?? `${siteUrl}/favicon.ico`;
|
||||||
}, [events]);
|
}, [events]);
|
||||||
|
|
||||||
const listStructuredData = useMemo(() => {
|
const listStructuredData = useMemo(() => {
|
||||||
@@ -93,7 +94,7 @@ export default function Events({
|
|||||||
listItem.endDate = eventItem.end_time;
|
listItem.endDate = eventItem.end_time;
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageUrl = toAbsoluteUrl(getThumbUrl(eventItem));
|
const imageUrl = toAbsoluteUrl(getEventCardImageUrl(eventItem));
|
||||||
if (imageUrl) {
|
if (imageUrl) {
|
||||||
listItem.image = imageUrl;
|
listItem.image = imageUrl;
|
||||||
}
|
}
|
||||||
@@ -210,12 +211,11 @@ export default function Events({
|
|||||||
<Link key={event.id} to={`/events/${event.slug}`} className="block h-full">
|
<Link key={event.id} to={`/events/${event.slug}`} className="block h-full">
|
||||||
<Card className="h-full flex flex-col hover:shadow-lg transition-shadow">
|
<Card className="h-full flex flex-col hover:shadow-lg transition-shadow">
|
||||||
<div className="w-full aspect-video overflow-hidden rounded-lg">
|
<div className="w-full aspect-video overflow-hidden rounded-lg">
|
||||||
<img
|
<ProgressiveImage
|
||||||
src={getThumbUrl(event)}
|
src={getEventCardImageUrl(event)}
|
||||||
alt={event.title}
|
alt={event.title}
|
||||||
className="w-full h-full object-cover"
|
wrapperClassName="h-full w-full"
|
||||||
loading="lazy"
|
className="h-full w-full object-cover"
|
||||||
decoding="async"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user