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>
392 lines
12 KiB
TypeScript
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);
|
|
}
|