Files
immich-folio/app/[...path]/PhotoGrid.tsx
ralksta b7217c4a76 feat: photography features & image optimizations
- Hero carousel: gallery.yaml hero accepts single UUID or array, crossfade component
- Full-bleed single albums: subpages with 1 album skip grid, render photos directly
- EXIF on hover: camera/lens/focal length overlay on grid items (toggle via exifOnHover)
- Customizable grid: columns/gap/aspectRatio/layout (masonry|uniform) via gallery.yaml
- Per-subpage grid overrides: each subpage can override global grid config
- True masonry: per-image aspect ratios from EXIF dimensions
- next/image migration: homepage hero, album covers, grid thumbnails, about portrait
- ThumbHash blur placeholders and dominant color backgrounds
- Custom next/image loader via image proxy with width/quality params
- Lightbox fade-in transition with dominant color background
- Password-protected subpages, health check, rate limiting, OG images
2026-02-14 10:30:27 +01:00

133 lines
4.3 KiB
TypeScript

/**
* PhotoGrid — client component wrapping the masonry grid and lightbox.
* Handles image click → lightbox open, keyboard nav, and EXIF fetching.
*/
'use client';
import { useState, useCallback, useEffect } from 'react';
import Image from 'next/image';
import { Lightbox } from '@/components/Lightbox';
export interface PhotoItem {
id: string;
thumbUrl: string;
previewUrl: string;
originalUrl: string;
exifUrl: string;
blurDataURL?: string;
dominantColor?: string;
/** Compact EXIF summary for hover overlay */
camera?: string;
lens?: string;
focalLength?: string;
/** Natural image aspect ratio (width/height) for masonry layout */
aspectRatio?: number;
}
interface PhotoGridProps {
assets: PhotoItem[];
albumId: string;
layout?: 'masonry' | 'uniform';
gridStyle?: React.CSSProperties;
}
export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridProps) {
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const openLightbox = useCallback((index: number) => {
setLightboxIndex(index);
}, []);
const closeLightbox = useCallback(() => {
setLightboxIndex(null);
}, []);
const goNext = useCallback(() => {
setLightboxIndex((prev) =>
prev !== null ? (prev + 1) % assets.length : null,
);
}, [assets.length]);
const goPrev = useCallback(() => {
setLightboxIndex((prev) =>
prev !== null ? (prev - 1 + assets.length) % assets.length : null,
);
}, [assets.length]);
// Keyboard navigation
useEffect(() => {
if (lightboxIndex === null) return;
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'Escape':
closeLightbox();
break;
case 'ArrowRight':
goNext();
break;
case 'ArrowLeft':
goPrev();
break;
}
};
document.addEventListener('keydown', handleKeyDown);
// Prevent body scroll when lightbox is open
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
};
}, [lightboxIndex, closeLightbox, goNext, goPrev]);
return (
<>
<div className={`photo-grid photo-grid--${layout}`} style={gridStyle}>
{assets.map((asset, index) => (
<div
key={asset.id}
className="photo-grid__item"
onClick={() => openLightbox(index)}
style={{
...(asset.dominantColor ? { backgroundColor: asset.dominantColor } : {}),
...(layout === 'masonry' && asset.aspectRatio
? { aspectRatio: `${asset.aspectRatio}` }
: {}),
}}
>
<Image
src={asset.thumbUrl}
alt=""
fill
sizes="(max-width: 600px) 50vw, (max-width: 1000px) 33vw, 25vw"
loading={index < 6 ? 'eager' : 'lazy'}
{...(asset.blurDataURL
? { placeholder: 'blur' as const, blurDataURL: asset.blurDataURL }
: {})}
/>
{(asset.camera || asset.lens) && (
<div className="photo-grid__item-exif">
{[asset.camera, asset.lens, asset.focalLength].filter(Boolean).join(' · ')}
</div>
)}
</div>
))}
</div>
{lightboxIndex !== null && (
<Lightbox
assets={assets}
currentIndex={lightboxIndex}
onClose={closeLightbox}
onNext={goNext}
onPrev={goPrev}
/>
)}
</>
);
}