/** * PhotoGrid — client component wrapping the masonry grid and lightbox. * Handles image click → lightbox open, keyboard nav, and EXIF fetching. * * 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. */ 'use client'; import { useState, useCallback, useEffect, useMemo } from 'react'; import Image from 'next/image'; import { Lightbox, type LightboxWatermark } from '@/components/Lightbox'; import { FadeIn } from '@/components/FadeIn'; import { ProofingProvider, useProofing } from '@/components/ProofingContext'; import { ProofingModal } from '@/components/ProofingModal'; export interface PhotoItem { id: string; type: 'image' | 'video'; thumbUrl: string; previewUrl: string; videoUrl?: 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[]; layout?: 'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow' | 'essay' | 'justified'; gridStyle?: React.CSSProperties; watermark?: LightboxWatermark; /** * Client proofing — favorite hearts, the selection bar and the send-off * modal. Off unless the caller opts in, so a grid rendered outside the * album routes never grows a delivery workflow by accident. */ proofing?: boolean; /** Offer the "send by email" button in the proofing modal. */ allowMailto?: boolean; } /** 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 } function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: PhotoGridProps) { const proofing = useProofing(); const [lightboxIndex, setLightboxIndex] = useState(null); const displayedAssets = useMemo(() => { if (proofing && proofing.isFilterActive) { return assets.filter((a) => proofing.isFavorite(a.id)); } return assets; }, [assets, proofing]); // ── Initial hash check (Client-only to avoid hydration mismatch) ── useEffect(() => { const idx = parsePhotoHash(window.location.hash); if (idx !== null && idx < displayedAssets.length) { // state from URL hash on mount only // eslint-disable-next-line react-hooks/set-state-in-effect setLightboxIndex(idx); } }, [displayedAssets.length]); // ── Sync URL hash when lightbox state changes ───────────────── useEffect(() => { if (lightboxIndex !== null && lightboxIndex < displayedAssets.length) { const hash = buildPhotoHash(lightboxIndex); if (window.location.hash !== hash) { window.history.replaceState(null, '', hash); } } }, [lightboxIndex, displayedAssets.length]); // ── Listen for browser back/forward (hash change) ───────────── useEffect(() => { const handleHashChange = () => { const idx = parsePhotoHash(window.location.hash); if (idx !== null && idx < displayedAssets.length) { setLightboxIndex(idx); } else { setLightboxIndex(null); } }; window.addEventListener('hashchange', handleHashChange); return () => window.removeEventListener('hashchange', handleHashChange); }, [displayedAssets.length]); const openLightbox = useCallback((index: number) => { setLightboxIndex(index); window.history.pushState(null, '', buildPhotoHash(index)); }, []); const closeLightbox = useCallback(() => { setLightboxIndex(null); window.history.pushState(null, '', window.location.pathname + window.location.search); }, []); const goNext = useCallback(() => { setLightboxIndex((prev) => (prev !== null ? (prev + 1) % displayedAssets.length : null)); }, [displayedAssets.length]); const goPrev = useCallback(() => { setLightboxIndex((prev) => prev !== null ? (prev - 1 + displayedAssets.length) % displayedAssets.length : null, ); }, [displayedAssets.length]); // Keyboard navigation and the scroll lock live in . const gridItems = useMemo(() => { return displayedAssets.map((asset, index) => { const isFav = proofing ? proofing.isFavorite(asset.id) : false; // Justified rows: the flex sizing must sit on the outermost grid child, // which is the FadeIn wrapper — not the .photo-grid__item inside it. // Aspect ratio drives both grow factor and basis; ~3:2 fallback for // assets without dimensions (e.g. videos) keeps the row math sane. const justifiedAr = asset.aspectRatio || 1.5; const justifiedStyle: React.CSSProperties | undefined = layout === 'justified' ? { flexGrow: justifiedAr, flexBasis: `calc(var(--grid-row-height, 300px) * ${justifiedAr})`, } : undefined; return (
openLightbox(index)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openLightbox(index); } }} role="button" tabIndex={0} aria-label={`View photo ${index + 1}`} aria-haspopup="dialog" style={{ ...(asset.dominantColor ? { backgroundColor: asset.dominantColor } : {}), ...((layout === 'masonry' || layout === 'showcase') && asset.aspectRatio ? { aspectRatio: `${asset.aspectRatio}` } : {}), position: 'relative', }} > {proofing && ( )} {asset.type === 'video' && ( )} {(asset.camera || asset.lens) && ( )}
); }); }, [displayedAssets, layout, openLightbox, proofing]); return ( <>
{gridItems}
{proofing && proofing.favorites.size > 0 && (
)} {proofing && } {lightboxIndex !== null && ( )} ); } export function PhotoGrid(props: PhotoGridProps) { const albumTokens = useMemo(() => props.assets.map((a) => a.id), [props.assets]); // Without the provider useProofing() returns null, and every proofing control // (hearts in the grid and in the lightbox, selection bar, modal) drops out on // its own. if (!props.proofing) { return ; } return ( ); }