Files
immich-folio/app/[...path]/PhotoGrid.tsx
2026-10-02 18:14:23 +02:00

513 lines
19 KiB
TypeScript

/**
* 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=<assetId>`, 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 type { ProofSessionInit } from '@/components/ProofingContext';
import { useProofing } from '@/components/useProofing';
import { ProofingProvider, ProofingModal, ProofSessionControls } from '@/components/ProofingLazy';
import { useDictionary } from '@/components/I18nProvider';
import { parsePhotoHash, parsePhotoQuery, buildPhotoQuery } from '@/lib/photoHash';
import { justifiedTileStyle } from '@/lib/justifiedRow';
const pad2 = (n: number) => String(n).padStart(2, '0');
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;
/**
* The full-resolution image for the lightbox zoom, fetched only when the
* visitor zooms (#467). Present only where zoom resolves on and the photo
* has a zoomable source; the size is the image's pixel size, upright.
*/
zoomUrl?: string;
zoomWidth?: number;
zoomHeight?: number;
}
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;
/**
* `encodeEmail()` of where the proofing dialog's "email to photographer"
* goes, when one is configured (#736). Lets the modal address the compose;
* without it the button is hidden.
*/
encodedMailto?: string;
/**
* 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;
/**
* A client proofing link's session (/proof/<token>). Turns proofing on and
* keeps the selection on the server instead of in the browser.
*/
proofSession?: ProofSessionInit;
}
function PhotoGridInner({
assets,
layout = 'masonry',
gridStyle,
watermark,
showExifPanel = true,
}: PhotoGridProps) {
const t = useDictionary();
const proofing = useProofing();
const [lightboxIndex, setLightboxIndex] = useState<number | null>(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=<assetId> 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;
}
// A `photo` that names no photo in this album — mistyped, truncated by a
// messenger, or a position like `?photo=5` (that was never a format) —
// opens nothing, so it should not stay in the address bar to be copied
// and shared on. Checked against the whole album, not `displayedAssets`:
// a real photo that the "selected only" filter hides is still a valid link.
if (token !== null && !assets.some((a) => a.id === token)) {
window.history.replaceState(
null,
'',
window.location.pathname +
buildPhotoQuery(window.location.search, null) +
window.location.hash,
);
}
const idx = parsePhotoHash(window.location.hash);
if (idx !== null && idx < displayedAssets.length) {
setLightboxIndex(idx);
}
}, [assets, 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]);
// ── The photo on screen can leave the list ─────────────────────
// With "selected only" on, un-hearting the photo in the viewer drops it from
// `displayedAssets`. The viewer keeps its position, which now shows the next
// favourite; past the end it steps back to the last one, and with nothing
// left it closes. `shownIndex` keeps this render valid (it used to hand the
// viewer an index past the end, and the page crashed); the effect settles
// the state and the URL.
const shownIndex =
lightboxIndex === null || displayedAssets.length === 0
? null
: Math.min(lightboxIndex, displayedAssets.length - 1);
useEffect(() => {
if (lightboxIndex === null || lightboxIndex < displayedAssets.length) return;
if (displayedAssets.length > 0) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLightboxIndex(displayedAssets.length - 1);
return;
}
setLightboxIndex(null);
window.history.replaceState(
null,
'',
window.location.pathname + buildPhotoQuery(window.location.search, null),
);
}, [lightboxIndex, displayedAssets.length]);
// ── 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 <Lightbox/>.
// A work's catalogue number on the wall label is its place in the album,
// not in the current view: the "selected only" filter must not renumber it.
const albumPosition = useMemo(() => new Map(assets.map((a, i) => [a.id, i + 1])), [assets]);
const gridItems = useMemo(() => {
return displayedAssets.map((asset, index) => {
const isFav = proofing ? proofing.isFavorite(asset.id) : false;
// A focal length alone is not a label, as before.
const exifLine =
asset.camera || asset.lens
? [asset.camera, asset.lens, asset.focalLength].filter(Boolean).join(' · ')
: '';
// Justified rows: the flex sizing must sit on the outermost grid child,
// which is the FadeIn wrapper — not the .photo-grid__item inside it.
const justifiedStyle: React.CSSProperties | undefined =
layout === 'justified' ? justifiedTileStyle(asset.aspectRatio) : undefined;
return (
<FadeIn
key={asset.id}
as="figure"
delay={index < 12 ? index * 50 : 0}
style={justifiedStyle}
>
<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}
data-lightbox-index={index}
aria-label={t.lightbox.openPhoto(index + 1)}
aria-haspopup="dialog"
style={{
...((layout === 'masonry' || layout === 'showcase') && asset.aspectRatio
? { aspectRatio: `${asset.aspectRatio}` }
: {}),
position: 'relative',
}}
>
{/* The photo's own box. Without a frame it covers the whole tile;
under passepartout globals.css insets it by the mat, so `fill`
and the placeholder colour stay on the photo, not on the mat. */}
<div
className="photo-grid__media"
style={asset.dominantColor ? { backgroundColor: asset.dominantColor } : undefined}
>
<Image
src={asset.thumbUrl}
alt={asset.caption ?? ''}
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 }
: {})}
/>
</div>
{proofing && (
<button
type="button"
className="photo-grid__fav-btn"
onClick={(e) => {
e.stopPropagation();
proofing.toggleFavorite(asset.id);
}}
aria-label={isFav ? t.proofing.removeFavorite : t.proofing.addFavorite}
title={isFav ? t.proofing.removeFavorite : t.proofing.addFavorite}
style={{
position: 'absolute',
top: '8px',
right: '8px',
zIndex: 5,
width: '44px',
height: '44px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'none',
border: 'none',
cursor: 'pointer',
color: isFav ? 'var(--lightbox-fav)' : 'rgba(255,255,255,0.85)',
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.6))',
padding: 0,
}}
>
<svg
viewBox="0 0 24 24"
width="24"
height="24"
fill={isFav ? 'currentColor' : 'none'}
stroke="currentColor"
strokeWidth="2"
>
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg>
</button>
)}
{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>
)}
{exifLine && (
<div className="photo-grid__item-exif" aria-hidden="true">
{exifLine}
</div>
)}
</div>
{/* The wall label under the photo, outside the tile and its clip.
globals.css hides it; a preset that hangs its photos as works
(kunsthalle) shows it. It carries only what the site already
publishes for the photo: its number, the caption that is the
image's alt text (absent unless the `caption` EXIF group is on),
and the EXIF line. The number and EXIF repeat the tile's own
label and the lightbox, so they stay out of the figure's name. */}
<figcaption className="photo-grid__label">
<span className="photo-grid__label-no" aria-hidden="true">
{pad2(albumPosition.get(asset.id) ?? index + 1)}
</span>
{asset.caption && <span className="photo-grid__label-title">{asset.caption}</span>}
{exifLine && (
<span className="photo-grid__label-exif" aria-hidden="true">
{exifLine}
</span>
)}
</figcaption>
</FadeIn>
);
});
}, [albumPosition, displayedAssets, layout, openLightbox, proofing, t]);
// Photos of this album that are selected. Not `favorites.size`: the stored set
// can also hold a photo since removed, or a favourite from another album whose
// name slugs the same way (they share `folio_fav_<name>`).
const selectedCount = proofing ? proofing.getSelectedTokens().length : 0;
return (
<>
{proofing?.isFilterActive && displayedAssets.length === 0 ? (
// Un-hearting the last favourite with the filter on left a blank page
// between the header and the footer. Say why, and offer the way back
// here as well as in the bar in the corner.
<div className="empty-state" role="status">
<p className="empty-state__text">{t.proofing.filterEmpty}</p>
<div className="empty-state__actions">
<button
type="button"
className="empty-state__button"
onClick={() => proofing.setIsFilterActive(false)}
>
{t.proofing.showAll}
</button>
</div>
</div>
) : (
<div className={`photo-grid photo-grid--${layout}`} style={gridStyle} data-lightbox-group>
{gridItems}
</div>
)}
{/* Kept while the filter is on: un-hearting the last favourite there
empties the grid, and the bar holds the only way back to all photos. */}
{proofing && !proofing.session && (selectedCount > 0 || proofing.isFilterActive) && (
<div
className="proofing-sticky-bar"
style={{
position: 'fixed',
bottom: '24px',
right: '24px',
zIndex: 990,
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '8px 16px',
borderRadius: 'var(--radius-lg)',
background: 'var(--bg-card, #1e1e1e)',
color: 'var(--text-primary, #ffffff)',
border: '1px solid var(--border-subtle, rgba(255,255,255,0.15))',
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
backdropFilter: 'blur(8px)',
}}
>
<button
type="button"
onClick={() => proofing.setIsFilterActive((prev) => !prev)}
style={{
background: proofing.isFilterActive
? 'var(--accent, #e60012)'
: 'var(--bg-card-hover)',
color: proofing.isFilterActive ? 'var(--on-accent, #fff)' : 'var(--text-primary)',
border: 'none',
padding: '6px 14px',
borderRadius: 'var(--radius-md)',
fontSize: '0.85rem',
fontWeight: 500,
cursor: 'pointer',
}}
>
{proofing.isFilterActive ? t.proofing.showAll : t.proofing.selected(selectedCount)}
</button>
<button
type="button"
onClick={() => proofing.setIsModalOpen(true)}
style={{
background: 'none',
border: 'none',
color: 'inherit',
fontSize: '0.85rem',
fontWeight: 500,
cursor: 'pointer',
textDecoration: 'underline',
}}
>
{t.proofing.shareExport}
</button>
</div>
)}
{proofing && !proofing.session && <ProofingModal />}
{proofing?.session && <ProofSessionControls />}
{shownIndex !== null && (
<Lightbox
assets={displayedAssets}
currentIndex={shownIndex}
onClose={closeLightbox}
onNext={goNext}
onPrev={goPrev}
watermark={watermark}
showExifToggle={showExifPanel}
/>
)}
</>
);
}
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 && !props.proofSession) {
return <PhotoGridInner {...props} />;
}
return (
<ProofingProvider
albumTokens={albumTokens}
albumName={props.albumName}
allowMailto={props.allowMailto ?? true}
encodedMailto={props.encodedMailto}
downloadArchiveUrl={props.downloadArchiveUrl}
session={props.proofSession}
>
<PhotoGridInner {...props} />
</ProofingProvider>
);
}