Files
eranos/src/components/ImageGallery.tsx
T

476 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useCallback, useEffect, useRef } from 'react';
import { ChevronLeft, ChevronRight, X, Download } from 'lucide-react';
import { Blurhash } from 'react-blurhash';
import { cn } from '@/lib/utils';
import { Skeleton } from '@/components/ui/skeleton';
import { useBlossomFallback } from '@/hooks/useBlossomFallback';
/** Minimal imeta fields needed for pre-load sizing. */
interface ImetaDimensions {
dim?: string;
blurhash?: string;
}
interface ImageGalleryProps {
images: string[];
className?: string;
/** Max images to show in the grid (default 4). */
maxVisible?: number;
/** Max height for images in the grid. */
maxGridHeight?: string;
/**
* Optional map from image URL to imeta metadata (dim, blurhash).
* Used to size skeleton placeholders correctly before the image loads.
*/
imetaMap?: Map<string, ImetaDimensions>;
}
/**
* Image grid + full-screen lightbox gallery.
* Clicking an image opens a cinematic overlay with prev/next navigation,
* keyboard support (arrow keys, Escape), and swipe gestures.
*/
export function ImageGallery({
images,
className,
maxVisible = 4,
maxGridHeight = '400px',
imetaMap,
}: ImageGalleryProps) {
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const visibleImages = images.slice(0, maxVisible);
const overflow = images.length - maxVisible;
const openLightbox = (index: number, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setLightboxIndex(index);
};
const closeLightbox = useCallback((e?: React.MouseEvent) => {
if (e) {
e.stopPropagation();
e.preventDefault();
}
setLightboxIndex(null);
}, []);
const goNext = useCallback(() => {
setLightboxIndex((prev) => (prev !== null ? (prev + 1) % images.length : null));
}, [images.length]);
const goPrev = useCallback(() => {
setLightboxIndex((prev) => (prev !== null ? (prev - 1 + images.length) % images.length : null));
}, [images.length]);
if (images.length === 0) return null;
return (
<>
{/* Thumbnail grid */}
<div
className={cn(
'mt-3 rounded-2xl overflow-hidden border border-border',
visibleImages.length > 1 && 'grid grid-cols-2 gap-0.5',
className,
)}
>
{visibleImages.map((url, i) => (
<GridImage
key={i}
url={url}
index={i}
visibleCount={visibleImages.length}
maxGridHeight={maxGridHeight}
overflow={i === visibleImages.length - 1 ? overflow : 0}
onOpen={(e) => openLightbox(i, e)}
dim={imetaMap?.get(url)?.dim}
blurhash={imetaMap?.get(url)?.blurhash}
/>
))}
</div>
{/* Lightbox */}
{lightboxIndex !== null && (
<Lightbox
images={images}
currentIndex={lightboxIndex}
onClose={closeLightbox}
onNext={goNext}
onPrev={goPrev}
/>
)}
</>
);
}
/**
* Parses a NIP-94 `dim` string like "1280x720" into `{ width, height }`.
* Returns undefined if the string is missing or malformed.
*/
function parseDim(dim: string | undefined): { width: number; height: number } | undefined {
if (!dim) return undefined;
const [w, h] = dim.split('x').map(Number);
if (!w || !h || isNaN(w) || isNaN(h)) return undefined;
return { width: w, height: h };
}
/** Single image tile with a blurhash/skeleton shown until the image loads. */
function GridImage({
url,
index,
visibleCount,
maxGridHeight,
overflow,
onOpen,
dim,
blurhash,
}: {
url: string;
index: number;
visibleCount: number;
maxGridHeight: string;
overflow: number;
onOpen: (e: React.MouseEvent) => void;
/** NIP-94 `dim` tag value, e.g. "1280x720". Used to size the placeholder before load. */
dim?: string;
/** NIP-94 `blurhash` tag value. Rendered as a canvas placeholder before the image loads. */
blurhash?: string;
}) {
const [loaded, setLoaded] = useState(false);
const [probedAspectRatio, setProbedAspectRatio] = useState<string | undefined>(undefined);
const imgRef = useRef<HTMLImageElement>(null);
const { src, onError } = useBlossomFallback(url);
// If the image is already cached by the browser, onLoad may have
// fired before the ref was attached. Check on mount.
useEffect(() => {
if (imgRef.current?.complete && imgRef.current.naturalWidth > 0) {
setLoaded(true);
if (!dim) {
const { naturalWidth: w, naturalHeight: h } = imgRef.current;
if (w && h) setProbedAspectRatio(`${w} / ${h}`);
}
}
}, [dim]);
const isSingle = visibleCount === 1;
// Derive intrinsic aspect ratio from the imeta `dim` tag, or from probed
// naturalWidth/naturalHeight after the image loads (for images with no dim).
const dimensions = parseDim(dim);
const aspectRatio = dimensions ? `${dimensions.width} / ${dimensions.height}` : probedAspectRatio;
// The button container owns all sizing so that both the placeholder and the
// <img> can be absolutely positioned to fill it cleanly.
//
// - Single image with dim or probed ratio → aspect-ratio drives the height naturally, capped at 85dvh
// - Single image without dim (loading) → fallback min-height of 200px until probed
// - Grid tile → fixed height derived from maxGridHeight
const containerStyle: React.CSSProperties = isSingle
? {
aspectRatio,
maxHeight: '85dvh',
minHeight: aspectRatio ? undefined : '200px',
}
: {
height:
visibleCount === 3 && index === 0
? maxGridHeight
: `calc(${maxGridHeight} / 2)`,
};
return (
<button
type="button"
className={cn(
'relative block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-primary',
visibleCount === 3 && index === 0 && 'row-span-2',
)}
style={containerStyle}
onClick={onOpen}
>
{/* Placeholder shown while the image is loading */}
{!loaded && (
blurhash ? (
// Blurhash canvas fills the container via CSS — pass small integer decode
// resolution; the canvas is stretched to 100%×100% by the style prop.
<Blurhash
hash={blurhash}
width={32}
height={32}
resolutionX={32}
resolutionY={32}
punch={1}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
}}
/>
) : (
<Skeleton className="absolute inset-0 w-full h-full rounded-none" />
)
)}
<img
ref={imgRef}
src={src}
alt=""
width={dimensions?.width}
height={dimensions?.height}
className={cn(
'absolute inset-0 w-full h-full object-cover transition-all duration-300 hover:scale-[1.02]',
loaded ? 'opacity-100' : 'opacity-0',
)}
loading="lazy"
onLoad={(e) => {
setLoaded(true);
if (!dim) {
const img = e.currentTarget;
if (img.naturalWidth && img.naturalHeight) {
setProbedAspectRatio(`${img.naturalWidth} / ${img.naturalHeight}`);
}
}
}}
onError={onError}
/>
{/* "+N" overlay on last visible image */}
{overflow > 0 && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center backdrop-blur-[2px]">
<span className="text-white text-2xl font-bold">+{overflow}</span>
</div>
)}
</button>
);
}
export interface LightboxProps {
images: string[];
currentIndex: number;
onClose: () => void;
onNext: () => void;
onPrev: () => void;
/** Custom content rendered on the left side of the top bar (replaces default counter). */
topBarLeft?: React.ReactNode;
/** Whether to show the download button (default true). */
showDownload?: boolean;
/** Max number of images before dot indicators are hidden on mobile (default 10). */
maxDotIndicators?: number;
}
export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, topBarLeft, showDownload = true, maxDotIndicators = 10 }: LightboxProps) {
const [isLoaded, setIsLoaded] = useState(false);
const [touchStart, setTouchStart] = useState<number | null>(null);
const [touchDelta, setTouchDelta] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const currentUrl = images[currentIndex];
const hasMultiple = images.length > 1;
// Reset load state when image changes
useEffect(() => {
setIsLoaded(false);
}, [currentIndex]);
// Keyboard navigation
useEffect(() => {
const handler = (e: KeyboardEvent) => {
switch (e.key) {
case 'Escape':
onClose();
break;
case 'ArrowRight':
if (hasMultiple) onNext();
break;
case 'ArrowLeft':
if (hasMultiple) onPrev();
break;
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [onClose, onNext, onPrev, hasMultiple]);
// Lock body scroll
useEffect(() => {
const original = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = original;
};
}, []);
// Touch handlers for swipe
const handleTouchStart = (e: React.TouchEvent) => {
setTouchStart(e.touches[0].clientX);
setIsDragging(true);
};
const handleTouchMove = (e: React.TouchEvent) => {
if (touchStart === null) return;
setTouchDelta(e.touches[0].clientX - touchStart);
};
const handleTouchEnd = () => {
if (Math.abs(touchDelta) > 60 && hasMultiple) {
if (touchDelta > 0) onPrev();
else onNext();
}
setTouchStart(null);
setTouchDelta(0);
setIsDragging(false);
};
// Click anywhere that isn't a button or the image itself to close
const handleBackdropClick = (e: React.MouseEvent) => {
const target = e.target as HTMLElement;
// Don't close if clicking on the image, a button, or inside the top bar
if (target.tagName === 'IMG' || target.closest('button') || target.closest('[data-gallery-topbar]')) return;
e.stopPropagation();
e.preventDefault();
onClose();
};
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
const a = document.createElement('a');
a.href = currentUrl;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.click();
};
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center animate-in fade-in duration-200"
onClick={handleBackdropClick}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/90 backdrop-blur-md" />
{/* Top bar */}
<div data-gallery-topbar className="absolute left-0 right-0 z-10 flex items-center justify-between px-4 py-3 safe-area-inset-top">
{/* Left side: custom content or default counter */}
{topBarLeft !== undefined ? topBarLeft : (
<>
{hasMultiple && (
<span className="text-white/80 text-sm font-medium tabular-nums">
{currentIndex + 1} / {images.length}
</span>
)}
{!hasMultiple && <span />}
</>
)}
{/* Actions */}
<div className="flex items-center gap-1">
{showDownload && (
<button
onClick={handleDownload}
className="p-2.5 rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors"
title="Open original"
>
<Download className="size-5" />
</button>
)}
<button
onClick={(e) => { e.stopPropagation(); e.preventDefault(); onClose(); }}
className="p-2.5 rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors"
title="Close (Esc)"
>
<X className="size-5" />
</button>
</div>
</div>
{/* Previous button */}
{hasMultiple && (
<button
onClick={(e) => { e.stopPropagation(); onPrev(); }}
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-black/40 text-white/80 hover:text-white hover:bg-black/60 backdrop-blur-sm transition-all hidden sm:flex"
title="Previous"
>
<ChevronLeft className="size-6" />
</button>
)}
{/* Next button */}
{hasMultiple && (
<button
onClick={(e) => { e.stopPropagation(); onNext(); }}
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-black/40 text-white/80 hover:text-white hover:bg-black/60 backdrop-blur-sm transition-all hidden sm:flex"
title="Next"
>
<ChevronRight className="size-6" />
</button>
)}
{/* Image */}
<div
className="relative z-[1] flex items-center justify-center w-full h-full px-4 py-16 sm:px-16"
style={{
transform: isDragging ? `translateX(${touchDelta}px)` : undefined,
transition: isDragging ? 'none' : 'transform 0.2s ease-out',
}}
>
{/* Spinner while loading */}
{!isLoaded && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="size-8 border-2 border-white/20 border-t-white/80 rounded-full animate-spin" />
</div>
)}
<LightboxImage
key={currentUrl}
url={currentUrl}
isLoaded={isLoaded}
onLoad={() => setIsLoaded(true)}
/>
</div>
{/* Dot indicators (mobile) */}
{hasMultiple && images.length <= maxDotIndicators && (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-10 flex items-center gap-1.5 sm:hidden">
{images.map((_, i) => (
<div
key={i}
className={cn(
'rounded-full transition-all duration-200',
i === currentIndex
? 'size-2 bg-white'
: 'size-1.5 bg-white/40',
)}
/>
))}
</div>
)}
</div>
);
}
/** Lightbox image with Blossom server fallback. */
function LightboxImage({ url, isLoaded, onLoad }: { url: string; isLoaded: boolean; onLoad: () => void }) {
const { src, onError } = useBlossomFallback(url);
return (
<img
src={src}
alt=""
className={cn(
'max-w-full max-h-full object-contain rounded-lg select-none transition-opacity duration-300',
isLoaded ? 'opacity-100' : 'opacity-0',
)}
onLoad={onLoad}
onError={onError}
draggable={false}
/>
);
}