release: v0.12.0 (#520)
Forty-one commits since v0.11.0. See CHANGELOG.md for the full list and the upgrade notes.
This commit is contained in:
@@ -6,6 +6,8 @@
|
||||
* - Previous/Next navigation (arrows + swipe)
|
||||
* - Close (Esc, click outside, X button)
|
||||
* - EXIF metadata panel (fetched on demand)
|
||||
* - Keyboard shortcut list (`?` or `h`), deliberately unadvertised
|
||||
* - Real fullscreen (`f`), where the browser offers it
|
||||
* - Preloads adjacent images
|
||||
*/
|
||||
|
||||
@@ -19,6 +21,11 @@ import { useSwipe } from '@/hooks/useSwipe';
|
||||
import styles from './Lightbox.module.css';
|
||||
import { useProofing } from './ProofingContext';
|
||||
import { IconHeart } from './Icons';
|
||||
import { useDictionary } from './I18nProvider';
|
||||
// From lib/config/schema directly: lib/config/index.ts pulls in `fs`, which a
|
||||
// client component cannot import.
|
||||
import { resolveWatermarkOpacity } from '@/lib/config/schema';
|
||||
import { formatCamera } from '@/lib/exif';
|
||||
|
||||
export interface LightboxWatermark {
|
||||
enabled?: boolean;
|
||||
@@ -50,7 +57,11 @@ export function Lightbox({
|
||||
watermark,
|
||||
showExifToggle = true,
|
||||
}: LightboxProps) {
|
||||
const t = useDictionary();
|
||||
const [showExif, setShowExif] = useState(false);
|
||||
const [showShortcuts, setShowShortcuts] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [canFullscreen, setCanFullscreen] = useState(false);
|
||||
const { exifData, exifLoading, fetchExif, clearExif } = useExif();
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
@@ -92,6 +103,61 @@ export function Lightbox({
|
||||
}
|
||||
}, [showExif, current, fetchExif]);
|
||||
|
||||
const toggleShortcuts = useCallback(() => {
|
||||
setShowShortcuts((open) => !open);
|
||||
}, []);
|
||||
|
||||
/*
|
||||
* Real fullscreen. The overlay already covers the viewport, but the browser's
|
||||
* own chrome sits above it — tab strip, URL bar, bookmarks — which is exactly
|
||||
* the frame a photograph should not be shown in.
|
||||
*
|
||||
* `fullscreenEnabled` is the honest gate: iPhone Safari implements the API on
|
||||
* `<video>` only, so the key and its shortcut row are simply absent there
|
||||
* rather than failing silently.
|
||||
*/
|
||||
useEffect(() => {
|
||||
setCanFullscreen(typeof document !== 'undefined' && !!document.fullscreenEnabled);
|
||||
}, []);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
const overlay = overlayRef.current;
|
||||
if (!overlay) return;
|
||||
// Every rejection here is a decision the browser is entitled to make
|
||||
// (denied permission, gesture requirement, already exiting) — the state
|
||||
// then simply stays where it was.
|
||||
if (document.fullscreenElement) {
|
||||
void document.exitFullscreen().catch(() => {});
|
||||
} else {
|
||||
void overlay.requestFullscreen().catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
/*
|
||||
* The state is derived from the event, never from the click: F11, the browser
|
||||
* Esc and the window manager all leave fullscreen without asking us.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const syncFullscreen = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener('fullscreenchange', syncFullscreen);
|
||||
return () => document.removeEventListener('fullscreenchange', syncFullscreen);
|
||||
}, []);
|
||||
|
||||
/*
|
||||
* Leaving the lightbox by any route — X, backdrop click, Esc, a navigation —
|
||||
* unmounts this component while the document is still fullscreen. Without
|
||||
* this the visitor is left in a fullscreen *gallery page* with no obvious way
|
||||
* back. Only ever exits a fullscreen this component asked for.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const overlay = overlayRef.current;
|
||||
return () => {
|
||||
if (overlay && document.fullscreenElement === overlay) {
|
||||
void document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Preload adjacent images (skip videos — they stream on demand)
|
||||
useEffect(() => {
|
||||
const preload = (index: number) => {
|
||||
@@ -119,7 +185,15 @@ export function Lightbox({
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
onClose();
|
||||
// Innermost layer first: Esc dismisses the shortcut list, then leaves
|
||||
// fullscreen, and only closes the viewer once nothing is stacked on
|
||||
// top of it. Most browsers swallow this Esc to exit fullscreen
|
||||
// themselves and never dispatch it — the middle branch is for the
|
||||
// ones that do dispatch it, which would otherwise close the lightbox
|
||||
// and leave the page behind it fullscreen.
|
||||
if (showShortcuts) setShowShortcuts(false);
|
||||
else if (document.fullscreenElement) void document.exitFullscreen().catch(() => {});
|
||||
else onClose();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
onNext();
|
||||
@@ -131,6 +205,19 @@ export function Lightbox({
|
||||
case 'I':
|
||||
if (showExifToggle) handleExifToggle();
|
||||
break;
|
||||
// `?` is matched on the produced character, not the physical key:
|
||||
// Shift+/ on a US layout, Shift+ß on a German one. `h` is the escape
|
||||
// hatch for layouts where `?` is awkward — and the one key someone
|
||||
// guesses without having been told.
|
||||
case '?':
|
||||
case 'h':
|
||||
case 'H':
|
||||
toggleShortcuts();
|
||||
break;
|
||||
case 'f':
|
||||
case 'F':
|
||||
if (canFullscreen) toggleFullscreen();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -141,7 +228,17 @@ export function Lightbox({
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [handleExifToggle, onClose, onNext, onPrev, showExifToggle]);
|
||||
}, [
|
||||
canFullscreen,
|
||||
handleExifToggle,
|
||||
onClose,
|
||||
onNext,
|
||||
onPrev,
|
||||
showExifToggle,
|
||||
showShortcuts,
|
||||
toggleFullscreen,
|
||||
toggleShortcuts,
|
||||
]);
|
||||
|
||||
// Click on overlay background → close
|
||||
const handleOverlayClick = useCallback(
|
||||
@@ -153,6 +250,28 @@ export function Lightbox({
|
||||
[onClose],
|
||||
);
|
||||
|
||||
/*
|
||||
* The advertised shortcuts, in the order they are worth learning. This is the
|
||||
* list the `?` panel renders — a key added to the handler above belongs here
|
||||
* too, or it stays as undiscoverable as the whole set was before.
|
||||
*/
|
||||
const shortcutRows = [
|
||||
{ keys: ['←', '→'], label: t.lightbox.shortcutNavigate },
|
||||
// Journal entries hide the EXIF toggle, so `i` does nothing there.
|
||||
...(showExifToggle ? [{ keys: ['I'], label: t.lightbox.shortcutInfo }] : []),
|
||||
// Absent where the browser has no element fullscreen to give (iPhone).
|
||||
...(canFullscreen
|
||||
? [
|
||||
{
|
||||
keys: ['F'],
|
||||
label: isFullscreen ? t.lightbox.shortcutExitFullscreen : t.lightbox.shortcutFullscreen,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ keys: ['?', 'H'], label: t.lightbox.shortcutList },
|
||||
{ keys: ['Esc'], label: t.lightbox.shortcutClose },
|
||||
];
|
||||
|
||||
const lightboxJsx = (
|
||||
<div
|
||||
className={styles.overlay}
|
||||
@@ -162,15 +281,15 @@ export function Lightbox({
|
||||
onTouchEnd={handleTouchEnd}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Image viewer"
|
||||
aria-label={t.lightbox.viewer}
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
ref={closeBtnRef}
|
||||
className={styles.close}
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
title="Close (Esc)"
|
||||
aria-label={t.lightbox.close}
|
||||
title={t.lightbox.closeTitle}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
@@ -190,8 +309,8 @@ export function Lightbox({
|
||||
<button
|
||||
className={`${styles.nav} ${styles.navPrev}`}
|
||||
onClick={onPrev}
|
||||
aria-label="Previous photo"
|
||||
title="Previous photo (Left arrow)"
|
||||
aria-label={t.lightbox.previous}
|
||||
title={t.lightbox.previousTitle}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
@@ -251,7 +370,7 @@ export function Lightbox({
|
||||
? styles.watermark_center
|
||||
: styles.watermark_bottom_right
|
||||
}`}
|
||||
style={{ opacity: (watermark.opacity ?? 50) / 100 }}
|
||||
style={{ opacity: resolveWatermarkOpacity(watermark.opacity) }}
|
||||
>
|
||||
{watermark.text}
|
||||
</div>
|
||||
@@ -262,8 +381,8 @@ export function Lightbox({
|
||||
<button
|
||||
className={`${styles.nav} ${styles.navNext}`}
|
||||
onClick={onNext}
|
||||
aria-label="Next photo"
|
||||
title="Next photo (Right arrow)"
|
||||
aria-label={t.lightbox.next}
|
||||
title={t.lightbox.nextTitle}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
@@ -297,11 +416,11 @@ export function Lightbox({
|
||||
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'}
|
||||
aria-label={isFav ? t.proofing.removeFromFavorites : t.proofing.addToFavorites}
|
||||
title={isFav ? t.proofing.removeFromFavorites : t.proofing.addToFavorites}
|
||||
>
|
||||
<IconHeart size={14} fill={isFav ? 'currentColor' : 'none'} aria-hidden="true" />
|
||||
{isFav ? 'Saved' : 'Favorite'}
|
||||
{isFav ? t.proofing.saved : t.proofing.favorite}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -312,10 +431,10 @@ export function Lightbox({
|
||||
onClick={handleExifToggle}
|
||||
aria-expanded={showExif}
|
||||
aria-controls="exif-panel"
|
||||
aria-label="Toggle photo info"
|
||||
title="Toggle photo info (i)"
|
||||
aria-label={t.lightbox.toggleInfo}
|
||||
title={t.lightbox.toggleInfoTitle}
|
||||
>
|
||||
{showExif ? 'Hide Info' : 'Info'}
|
||||
{showExif ? t.lightbox.hideInfo : t.lightbox.info}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -324,52 +443,52 @@ export function Lightbox({
|
||||
<div id="exif-panel" className={styles.exifPanel}>
|
||||
{exifLoading ? (
|
||||
<div className={styles.exifRow}>
|
||||
<span className={styles.exifLabel}>Loading...</span>
|
||||
<span className={styles.exifLabel}>{t.lightbox.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.exifLabel}>{t.lightbox.camera}</span>
|
||||
<span className={styles.exifValue}>
|
||||
{[exifData.make, exifData.model].filter(Boolean).join(' ')}
|
||||
{formatCamera(exifData.make, exifData.model)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{exifData.lensModel && (
|
||||
<div className={styles.exifRow}>
|
||||
<span className={styles.exifLabel}>Lens</span>
|
||||
<span className={styles.exifLabel}>{t.lightbox.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.exifLabel}>{t.lightbox.focalLength}</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.exifLabel}>{t.lightbox.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.exifLabel}>{t.lightbox.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.exifLabel}>{t.lightbox.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.exifLabel}>{t.lightbox.location}</span>
|
||||
<span className={styles.exifValue}>
|
||||
{[exifData.city, exifData.country].filter(Boolean).join(', ')}
|
||||
</span>
|
||||
@@ -378,11 +497,37 @@ export function Lightbox({
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.exifRow}>
|
||||
<span className={styles.exifLabel}>No EXIF data available</span>
|
||||
<span className={styles.exifLabel}>{t.lightbox.noExif}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/*
|
||||
The shortcuts have no control of their own and are not advertised: a
|
||||
permanent button in the corner of a photograph costs every visitor
|
||||
something, and the keys are worth nothing to the ones who would never
|
||||
press them anyway. Whoever tries `?` or `h` finds them.
|
||||
*/}
|
||||
{showShortcuts && (
|
||||
<div id="lightbox-shortcuts" className={styles.shortcutsPanel}>
|
||||
<p className={styles.shortcutsTitle}>{t.lightbox.shortcuts}</p>
|
||||
<dl className={styles.shortcutsList}>
|
||||
{shortcutRows.map((row) => (
|
||||
<div key={row.label} className={styles.shortcutsRow}>
|
||||
<dt className={styles.shortcutsKeys}>
|
||||
{row.keys.map((key) => (
|
||||
<kbd key={key} className={styles.kbd}>
|
||||
{key}
|
||||
</kbd>
|
||||
))}
|
||||
</dt>
|
||||
<dd className={styles.shortcutsLabel}>{row.label}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user