Files
immich-folio/app/[...path]/PhotoGrid.tsx
Ralksta 2f546f440d release: v0.11.0 (#459)
Ships `dev` as **v0.11.0**. `main` contains nothing that `dev` does not,
so this is a clean sync.

## Headline changes since v0.10.0

- **Journal & Journal Studio** — photo essays at `/journal`,
split-screen block editor with live preview, drafts, per-entry
passwords, cover images
- **First-run setup wizard at `/install`** — configure a fresh
deployment from the browser; credentials are verified against Immich
before anything is written, and the wizard is gated by a one-time token
- **About page editor and toggle** in the admin panel
- **Per-album photo order** independent of Immich, with a drag & drop
editor
- **Custom favicon upload**
- **Experimental portfolio features** — justified grid, `cover` hero,
unlisted subpages, per-album grid overrides, cover focal points,
external nav links
- **Security** — asset tokens length-capped before decoding (#423),
journal author markdown escaped rather than filtered, journal file paths
contained, admin session key derived with scrypt (#432), Next.js 16.3.0
for CVE-2025-13465
- **Fixed** — client proofing honours its own configuration again
(#454); journal photo rendering (#432)

Full detail in [CHANGELOG.md](CHANGELOG.md#0110--2026-08-15).

## Verified

`npx tsc --noEmit` clean · `npx vitest run` 435 tests pass · `npm run
build` succeeds · lint 0 errors · `prettier --check` clean · local
`docker build` of the release tree.

## After merge

Tag `v0.11.0` on `main` and publish the release — that triggers
`docker-publish.yml`, which pushes the image and moves `latest`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 09:09:18 +02:00

339 lines
12 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 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<number | null>(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 <Lightbox/>.
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 (
<FadeIn key={asset.id} 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}
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',
}}
>
<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 }
: {})}
/>
{proofing && (
<button
type="button"
className="photo-grid__fav-btn"
onClick={(e) => {
e.stopPropagation();
proofing.toggleFavorite(asset.id);
}}
aria-label={isFav ? 'Remove favorite' : 'Add favorite'}
title={isFav ? 'Remove favorite' : 'Add favorite'}
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 ? '#ff4d4f' : '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>
)}
{(asset.camera || asset.lens) && (
<div className="photo-grid__item-exif" aria-hidden="true">
{[asset.camera, asset.lens, asset.focalLength].filter(Boolean).join(' · ')}
</div>
)}
</div>
</FadeIn>
);
});
}, [displayedAssets, layout, openLightbox, proofing]);
return (
<>
<div className={`photo-grid photo-grid--${layout}`} style={gridStyle}>
{gridItems}
</div>
{proofing && proofing.favorites.size > 0 && (
<div
className="proofing-sticky-bar"
style={{
position: 'fixed',
bottom: '24px',
right: '24px',
zIndex: 990,
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '8px 16px',
borderRadius: '30px',
background: 'var(--bg-surface, #1e1e1e)',
color: 'var(--text-primary, #ffffff)',
border: '1px solid var(--border-color, 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)'
: 'rgba(255,255,255,0.15)',
color: '#fff',
border: 'none',
padding: '6px 14px',
borderRadius: '20px',
fontSize: '0.85rem',
fontWeight: 500,
cursor: 'pointer',
}}
>
{proofing.isFilterActive ? 'Show All' : `❤️ ${proofing.favorites.size} Selected`}
</button>
<button
type="button"
onClick={() => proofing.setIsModalOpen(true)}
style={{
background: 'none',
border: 'none',
color: 'inherit',
fontSize: '0.85rem',
fontWeight: 500,
cursor: 'pointer',
textDecoration: 'underline',
}}
>
Share & Export
</button>
</div>
)}
{proofing && <ProofingModal />}
{lightboxIndex !== null && (
<Lightbox
assets={displayedAssets}
currentIndex={lightboxIndex}
onClose={closeLightbox}
onNext={goNext}
onPrev={goPrev}
watermark={watermark}
/>
)}
</>
);
}
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 <PhotoGridInner {...props} />;
}
return (
<ProofingProvider albumTokens={albumTokens} allowMailto={props.allowMailto ?? true}>
<PhotoGridInner {...props} />
</ProofingProvider>
);
}