Files
immich-folio/components/Lightbox.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

392 lines
12 KiB
TypeScript

/**
* Lightbox — fullscreen image viewer with navigation and EXIF info.
*
* Features:
* - Full-resolution image display
* - Previous/Next navigation (arrows + swipe)
* - Close (Esc, click outside, X button)
* - EXIF metadata panel (fetched on demand)
* - Preloads adjacent images
*/
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import type { PhotoItem } from '@/app/[...path]/PhotoGrid';
import { useExif } from '@/hooks/useExif';
import { useSwipe } from '@/hooks/useSwipe';
import styles from './Lightbox.module.css';
import { useProofing } from './ProofingContext';
import { IconHeart } from './Icons';
export interface LightboxWatermark {
enabled?: boolean;
text?: string;
opacity?: number;
position?: 'bottom-right' | 'bottom-left' | 'center' | string;
}
interface LightboxProps {
assets: PhotoItem[];
currentIndex: number;
onClose: () => void;
onNext: () => void;
onPrev: () => void;
watermark?: LightboxWatermark;
/**
* Show the EXIF ("Info") toggle. Off for editorial contexts such as journal
* entries, where a technical data panel interrupts the story.
*/
showExifToggle?: boolean;
}
export function Lightbox({
assets,
currentIndex,
onClose,
onNext,
onPrev,
watermark,
showExifToggle = true,
}: LightboxProps) {
const [showExif, setShowExif] = useState(false);
const { exifData, exifLoading, fetchExif, clearExif } = useExif();
const [imageLoaded, setImageLoaded] = useState(false);
const overlayRef = useRef<HTMLDivElement>(null);
const closeBtnRef = useRef<HTMLButtonElement>(null);
const current = assets[currentIndex];
const proofing = useProofing();
const isFav = proofing && current ? proofing.isFavorite(current.id) : false;
const [mounted, setMounted] = useState(false);
// Mount guard — createPortal needs document.body (client-only)
useEffect(() => {
setMounted(true);
}, []);
// Auto-focus the close button when the modal mounts
useEffect(() => {
if (mounted) {
closeBtnRef.current?.focus();
}
}, [mounted]);
// Reset EXIF data when switching images; refetch if panel is open
useEffect(() => {
clearExif();
setImageLoaded(false);
if (showExif && current) {
fetchExif(current.exifUrl);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentIndex]);
// When the toggle is turned on or the image changes while open, fetch
const handleExifToggle = useCallback(() => {
const next = !showExif;
setShowExif(next);
if (next && current) {
fetchExif(current.exifUrl);
}
}, [showExif, current, fetchExif]);
// Preload adjacent images (skip videos — they stream on demand)
useEffect(() => {
const preload = (index: number) => {
if (index >= 0 && index < assets.length && assets[index].type !== 'video') {
const img = new Image();
img.src = assets[index].previewUrl;
}
};
preload(currentIndex + 1);
preload(currentIndex - 1);
}, [currentIndex, assets]);
const { handleTouchStart, handleTouchEnd } = useSwipe({
onSwipeLeft: onNext,
onSwipeRight: onPrev,
});
/*
* Keyboard control and the scroll lock belong to the lightbox, not to whoever
* opens it: PhotoGrid used to install the arrow keys itself, so every other
* caller (EssayView, i.e. journal entries and photo essays) silently had no
* keyboard navigation at all.
*/
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'Escape':
onClose();
break;
case 'ArrowRight':
onNext();
break;
case 'ArrowLeft':
onPrev();
break;
case 'i':
case 'I':
if (showExifToggle) handleExifToggle();
break;
}
};
document.addEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
};
}, [handleExifToggle, onClose, onNext, onPrev, showExifToggle]);
// Click on overlay background → close
const handleOverlayClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === overlayRef.current) {
onClose();
}
},
[onClose],
);
const lightboxJsx = (
<div
className={styles.overlay}
ref={overlayRef}
onClick={handleOverlayClick}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
role="dialog"
aria-modal="true"
aria-label="Image viewer"
>
{/* Close button */}
<button
ref={closeBtnRef}
className={styles.close}
onClick={onClose}
aria-label="Close"
title="Close (Esc)"
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
{/* Previous button */}
<button
className={`${styles.nav} ${styles.navPrev}`}
onClick={onPrev}
aria-label="Previous photo"
title="Previous photo (Left arrow)"
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
{/* Image or Video */}
<div
className={styles.imageContainer}
style={{
backgroundColor: current.dominantColor || '#000',
backgroundImage:
current.type !== 'video' && current.blurDataURL
? `url(${current.blurDataURL})`
: undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
{current.type === 'video' && current.videoUrl ? (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video
className={`${styles.image}${imageLoaded ? ` ${styles.imageLoaded}` : ''}`}
src={current.videoUrl}
controls
autoPlay={false}
playsInline
onCanPlay={() => setImageLoaded(true)}
onError={() => console.error(`[Lightbox] Failed to load video: ${current.videoUrl}`)}
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
className={`${styles.image}${imageLoaded ? ` ${styles.imageLoaded}` : ''}`}
src={current.previewUrl}
alt=""
draggable={false}
onLoad={() => setImageLoaded(true)}
onError={() => console.error(`[Lightbox] Failed to load image: ${current.previewUrl}`)}
/>
)}
{watermark?.enabled && watermark.text && (
<div
className={`${styles.watermark} ${
watermark.position === 'bottom-left'
? styles.watermark_bottom_left
: watermark.position === 'center'
? styles.watermark_center
: styles.watermark_bottom_right
}`}
style={{ opacity: (watermark.opacity ?? 50) / 100 }}
>
{watermark.text}
</div>
)}
</div>
{/* Next button */}
<button
className={`${styles.nav} ${styles.navNext}`}
onClick={onNext}
aria-label="Next photo"
title="Next photo (Right arrow)"
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="9 6 15 12 9 18" />
</svg>
</button>
{/* Counter */}
<div className={styles.counter} aria-live="polite" aria-atomic="true">
<span className="sr-only">
Photo {currentIndex + 1} of {assets.length}
</span>
<span aria-hidden="true">
{currentIndex + 1} / {assets.length}
</span>
</div>
{/* Proofing favorite button */}
{proofing && current && (
<button
className={`${styles.infoToggle} ${styles.favToggle}`}
style={{
color: isFav ? '#ff4d4f' : 'inherit',
fontWeight: isFav ? 600 : 400,
}}
onClick={() => proofing.toggleFavorite(current.id)}
aria-label={isFav ? 'Remove from favorites' : 'Add to favorites'}
title={isFav ? 'Remove from favorites' : 'Add to favorites'}
>
<IconHeart size={14} fill={isFav ? 'currentColor' : 'none'} aria-hidden="true" />
{isFav ? 'Saved' : 'Favorite'}
</button>
)}
{/* EXIF toggle */}
{showExifToggle && (
<button
className={styles.infoToggle}
onClick={handleExifToggle}
aria-expanded={showExif}
aria-controls="exif-panel"
aria-label="Toggle photo info"
title="Toggle photo info (i)"
>
{showExif ? 'Hide Info' : 'Info'}
</button>
)}
{/* EXIF panel */}
{showExif && (
<div id="exif-panel" className={styles.exifPanel}>
{exifLoading ? (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>Loading...</span>
</div>
) : exifData ? (
<>
{exifData.description && <p className={styles.exifCaption}>{exifData.description}</p>}
{exifData.model && (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>Camera</span>
<span className={styles.exifValue}>
{[exifData.make, exifData.model].filter(Boolean).join(' ')}
</span>
</div>
)}
{exifData.lensModel && (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>Lens</span>
<span className={styles.exifValue}>{exifData.lensModel}</span>
</div>
)}
{exifData.focalLength && (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>Focal Length</span>
<span className={styles.exifValue}>{exifData.focalLength}mm</span>
</div>
)}
{exifData.fNumber && (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>Aperture</span>
<span className={styles.exifValue}>ƒ/{exifData.fNumber}</span>
</div>
)}
{exifData.exposureTime && (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>Shutter</span>
<span className={styles.exifValue}>{exifData.exposureTime}s</span>
</div>
)}
{exifData.iso && (
<div className={`${styles.exifRow} ${styles.exifRowIso}`}>
<span className={styles.exifLabel}>ISO</span>
<span className={styles.exifValue}>{exifData.iso}</span>
</div>
)}
{(exifData.city || exifData.country) && (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>Location</span>
<span className={styles.exifValue}>
{[exifData.city, exifData.country].filter(Boolean).join(', ')}
</span>
</div>
)}
</>
) : (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>No EXIF data available</span>
</div>
)}
</div>
)}
</div>
);
if (!mounted) return null;
return createPortal(lightboxJsx, document.body);
}