/** * PhotoGrid — client component wrapping the masonry grid and lightbox. * Handles image click → lightbox open, keyboard nav, and EXIF fetching. * * Deep-link support: syncs the lightbox with `?photo=`, a stable * per-photo id rather than a position, so reordering or deleting other * photos in the album does not repoint an already-shared link (#588). A * legacy `#photo-N` hash is still read on the initial load — those links * have been shared for months — but the grid never writes that form again. */ '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'; import { useDictionary } from '@/components/I18nProvider'; import { parsePhotoHash, parsePhotoQuery, buildPhotoQuery } from '@/lib/photoHash'; 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; /** Immich asset description, used as alt text. Absent when captions are off. */ caption?: string; /** Present only when the surrounding album offers its originals (#475). */ downloadUrl?: string; } interface PhotoGridProps { assets: PhotoItem[]; layout?: 'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow' | 'essay' | 'justified'; gridStyle?: React.CSSProperties; watermark?: LightboxWatermark; /** * Offer the lightbox info panel. Off when every EXIF group is switched off, * so the `i` key does not open an empty panel (#506). */ showExifPanel?: boolean; /** * 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; /** * The ZIP endpoint for this album, when it offers downloads. Lets the * proofing modal offer a "download selected" action (#475). */ downloadArchiveUrl?: string; /** * The album's display name. Scopes the proofing favourites to this album — * without it every album shares the provider's default storage key, so a * selection made in one album leaks into the next. */ albumName?: string; } function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark, showExifPanel = true, }: PhotoGridProps) { const t = useDictionary(); 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 deep-link check (client-only to avoid hydration mismatch) ── // ?photo= wins when present; #photo-N is read only as a fallback, // for a link shared before this format existed. useEffect(() => { const token = parsePhotoQuery(window.location.search); const byToken = token ? displayedAssets.findIndex((a) => a.id === token) : -1; if (byToken >= 0) { // eslint-disable-next-line react-hooks/set-state-in-effect setLightboxIndex(byToken); return; } const idx = parsePhotoHash(window.location.hash); if (idx !== null && idx < displayedAssets.length) { setLightboxIndex(idx); } }, [displayedAssets]); // ── Sync the URL's `photo` param when the lightbox state changes ── useEffect(() => { const assetId = lightboxIndex !== null && lightboxIndex < displayedAssets.length ? displayedAssets[lightboxIndex].id : null; if (assetId === null) return; const query = buildPhotoQuery(window.location.search, assetId); // Building the full URL (not just the search string) drops any leftover // #photo-N hash from a legacy link this page was opened with. const url = window.location.pathname + query; if (window.location.pathname + window.location.search !== url || window.location.hash) { window.history.replaceState(null, '', url); } }, [lightboxIndex, displayedAssets]); // ── Listen for browser back/forward ──────────────────────────── useEffect(() => { const handlePopState = () => { const token = parsePhotoQuery(window.location.search); const byToken = token ? displayedAssets.findIndex((a) => a.id === token) : -1; setLightboxIndex(byToken >= 0 ? byToken : null); }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); }, [displayedAssets]); const openLightbox = useCallback( (index: number) => { setLightboxIndex(index); const asset = displayedAssets[index]; if (asset) { window.history.pushState( null, '', window.location.pathname + buildPhotoQuery(window.location.search, asset.id), ); } }, [displayedAssets], ); const closeLightbox = useCallback(() => { setLightboxIndex(null); window.history.pushState( null, '', window.location.pathname + buildPhotoQuery(window.location.search, null), ); }, []); 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={t.lightbox.openPhoto(index + 1)} aria-haspopup="dialog" style={{ ...(asset.dominantColor ? { backgroundColor: asset.dominantColor } : {}), ...((layout === 'masonry' || layout === 'showcase') && asset.aspectRatio ? { aspectRatio: `${asset.aspectRatio}` } : {}), position: 'relative', }} > {asset.caption {proofing && ( )} {asset.type === 'video' && ( )} {(asset.camera || asset.lens) && ( )}
); }); }, [displayedAssets, layout, openLightbox, proofing, t]); 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 ( ); }