2026-02-14 00:59:57 +01:00
|
|
|
/**
|
|
|
|
|
* PhotoGrid — client component wrapping the masonry grid and lightbox.
|
|
|
|
|
* Handles image click → lightbox open, keyboard nav, and EXIF fetching.
|
2026-02-14 12:14:56 +01:00
|
|
|
*
|
|
|
|
|
* Deep-link support: syncs lightbox index with the URL hash (#photo-N).
|
|
|
|
|
* Sharing a link with a hash opens the lightbox to that photo directly.
|
2026-02-14 00:59:57 +01:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
'use client';
|
|
|
|
|
|
2026-03-11 23:32:40 +01:00
|
|
|
import { useState, useCallback, useEffect, useMemo } from 'react';
|
2026-02-14 10:30:27 +01:00
|
|
|
import Image from 'next/image';
|
2026-02-14 00:59:57 +01:00
|
|
|
import { Lightbox } from '@/components/Lightbox';
|
2026-02-14 11:47:26 +01:00
|
|
|
import { FadeIn } from '@/components/FadeIn';
|
2026-02-14 00:59:57 +01:00
|
|
|
|
|
|
|
|
export interface PhotoItem {
|
2026-02-14 11:47:26 +01:00
|
|
|
id: string;
|
2026-05-29 17:05:12 +02:00
|
|
|
type: 'image' | 'video';
|
2026-02-14 11:47:26 +01:00
|
|
|
thumbUrl: string;
|
|
|
|
|
previewUrl: string;
|
2026-05-29 17:05:12 +02:00
|
|
|
videoUrl?: string;
|
2026-02-14 11:47:26 +01:00
|
|
|
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;
|
2026-02-14 00:59:57 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface PhotoGridProps {
|
2026-02-14 11:47:26 +01:00
|
|
|
assets: PhotoItem[];
|
feat: add noir, monograph, botanica presets + stacked/typographic/mosaic hero styles + showcase/filmstrip/editorial-flow gallery layouts
- 3 new theme presets: noir (darkroom analog), monograph (typographic book), botanica (organic nature)
- 3 new hero styles: stacked (fullbleed + thumbnail strip), typographic (text-only), mosaic (multi-image grid)
- 3 new gallery layouts: showcase (featured + grid), filmstrip (horizontal scroll), editorial-flow (alternating pairs)
- Extended config types, validation, and CSS tokens for all new options
- Updated theming docs with new presets table, descriptions, and example configs
2026-02-15 21:58:31 +01:00
|
|
|
layout?: 'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow';
|
2026-02-14 11:47:26 +01:00
|
|
|
gridStyle?: React.CSSProperties;
|
2026-02-14 00:59:57 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-14 12:14:56 +01:00
|
|
|
/** Parse `#photo-N` from a hash string. Returns index or null. */
|
|
|
|
|
function parsePhotoHash(hash: string): number | null {
|
|
|
|
|
const match = hash.match(/^#photo-(\d+)$/);
|
|
|
|
|
if (!match) return null;
|
|
|
|
|
const idx = parseInt(match[1], 10) - 1; // 1-indexed in URL, 0-indexed internally
|
|
|
|
|
return idx >= 0 ? idx : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Build the hash string for a given 0-based index. */
|
|
|
|
|
function buildPhotoHash(index: number): string {
|
|
|
|
|
return `#photo-${index + 1}`; // 1-indexed for user-friendliness
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 10:30:27 +01:00
|
|
|
export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridProps) {
|
2026-03-07 10:18:20 +01:00
|
|
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
|
|
|
|
|
|
|
|
|
// ── Initial hash check (Client-only to avoid hydration mismatch) ──
|
|
|
|
|
useEffect(() => {
|
2026-02-14 12:14:56 +01:00
|
|
|
const idx = parsePhotoHash(window.location.hash);
|
2026-03-07 10:18:20 +01:00
|
|
|
if (idx !== null && idx < assets.length) {
|
2026-03-08 16:26:05 +01:00
|
|
|
// state from URL hash on mount only (equivalent to useState lazy initializer for
|
|
|
|
|
// client-only browser APIs).
|
|
|
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
2026-03-07 10:18:20 +01:00
|
|
|
setLightboxIndex(idx);
|
|
|
|
|
}
|
|
|
|
|
}, [assets.length]);
|
2026-02-14 12:14:56 +01:00
|
|
|
|
|
|
|
|
// ── Sync URL hash when lightbox state changes ─────────────────
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (lightboxIndex !== null && lightboxIndex < assets.length) {
|
|
|
|
|
const hash = buildPhotoHash(lightboxIndex);
|
|
|
|
|
// Use replaceState to avoid flooding history with every prev/next tap
|
|
|
|
|
if (window.location.hash !== hash) {
|
|
|
|
|
window.history.replaceState(null, '', hash);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, [lightboxIndex, assets.length]);
|
|
|
|
|
|
|
|
|
|
// ── Listen for browser back/forward (hash change) ─────────────
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handleHashChange = () => {
|
|
|
|
|
const idx = parsePhotoHash(window.location.hash);
|
|
|
|
|
if (idx !== null && idx < assets.length) {
|
|
|
|
|
setLightboxIndex(idx);
|
|
|
|
|
} else {
|
|
|
|
|
setLightboxIndex(null);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
window.addEventListener('hashchange', handleHashChange);
|
|
|
|
|
return () => window.removeEventListener('hashchange', handleHashChange);
|
|
|
|
|
}, [assets.length]);
|
|
|
|
|
|
2026-02-15 11:23:51 +01:00
|
|
|
const openLightbox = useCallback((index: number) => {
|
|
|
|
|
setLightboxIndex(index);
|
|
|
|
|
// Push state (not replace) so the user can press Back to close
|
|
|
|
|
window.history.pushState(null, '', buildPhotoHash(index));
|
|
|
|
|
}, []);
|
2026-02-14 11:47:26 +01:00
|
|
|
|
|
|
|
|
const closeLightbox = useCallback(() => {
|
|
|
|
|
setLightboxIndex(null);
|
2026-02-14 12:14:56 +01:00
|
|
|
// Remove hash from URL cleanly
|
|
|
|
|
window.history.pushState(null, '', window.location.pathname + window.location.search);
|
2026-02-14 11:47:26 +01:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
2026-03-11 23:32:40 +01:00
|
|
|
// Memoize grid items to prevent re-renders of the entire grid (which includes
|
|
|
|
|
// Image and FadeIn components) when navigating the lightbox.
|
|
|
|
|
const gridItems = useMemo(() => {
|
|
|
|
|
return assets.map((asset, index) => (
|
|
|
|
|
<FadeIn key={asset.id} delay={index < 12 ? index * 50 : 0}>
|
|
|
|
|
<div
|
|
|
|
|
className={`photo-grid__item${
|
|
|
|
|
layout === 'showcase' && index === 0 ? ' photo-grid__featured' : ''
|
|
|
|
|
}`}
|
|
|
|
|
onClick={() => openLightbox(index)}
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
openLightbox(index);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
role="button"
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
aria-label={`View photo ${index + 1}`}
|
2026-05-05 10:24:39 +00:00
|
|
|
aria-haspopup="dialog"
|
2026-03-11 23:32:40 +01:00
|
|
|
style={{
|
|
|
|
|
...(asset.dominantColor ? { backgroundColor: asset.dominantColor } : {}),
|
|
|
|
|
...((layout === 'masonry' || layout === 'showcase') && 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 }
|
|
|
|
|
: {})}
|
|
|
|
|
/>
|
2026-05-29 17:05:12 +02:00
|
|
|
{asset.type === 'video' && (
|
|
|
|
|
<div className="photo-grid__item-play" aria-hidden="true">
|
|
|
|
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
|
|
|
|
<circle cx="12" cy="12" r="12" fillOpacity="0.55" />
|
|
|
|
|
<polygon points="10,8 10,16 17,12" fill="white" />
|
|
|
|
|
</svg>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-03-11 23:32:40 +01:00
|
|
|
{(asset.camera || asset.lens) && (
|
2026-05-05 10:24:39 +00:00
|
|
|
<div className="photo-grid__item-exif" aria-hidden="true">
|
2026-03-11 23:32:40 +01:00
|
|
|
{[asset.camera, asset.lens, asset.focalLength].filter(Boolean).join(' · ')}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</FadeIn>
|
|
|
|
|
));
|
|
|
|
|
}, [assets, layout, openLightbox]);
|
|
|
|
|
|
2026-02-14 11:47:26 +01:00
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<div className={`photo-grid photo-grid--${layout}`} style={gridStyle}>
|
2026-03-11 23:32:40 +01:00
|
|
|
{gridItems}
|
2026-02-14 11:47:26 +01:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{lightboxIndex !== null && (
|
|
|
|
|
<Lightbox
|
|
|
|
|
assets={assets}
|
|
|
|
|
currentIndex={lightboxIndex}
|
|
|
|
|
onClose={closeLightbox}
|
|
|
|
|
onNext={goNext}
|
|
|
|
|
onPrev={goPrev}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
);
|
2026-02-14 00:59:57 +01:00
|
|
|
}
|