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

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