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:
Ralksta
2026-08-23 23:18:14 +02:00
committed by GitHub
parent 2f546f440d
commit 10ec68f493
119 changed files with 6888 additions and 758 deletions

View File

@@ -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>
);