513 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|