F(frontend): add image lightbox and derivative fallbacks
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user