release: v0.13.0 (#535)

Twenty-nine commits since v0.12.0. See CHANGELOG.md for the full list
and the upgrade notes.

Ten additions — the SEO package (site URL, robots.txt, sitemap.xml,
JSON-LD), slideshow mode, opt-in original downloads, copy-link,
previous/next album navigation, alt text from the Immich description,
per-album location precision, the update notice, a Help tab, and a
subpage cover grid separate from its photo grid. Two fixes: CJK album
slugs, and the lightbox control bar.

Photo of the Day (#476) was built on `dev` and reverted as not planned;
it never appears in a release.

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

https://claude.ai/code/session_01DmsHVRL9yQvckUB1ZpVWre

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Ralksta
2026-08-25 10:08:12 +02:00
committed by GitHub
parent 10ec68f493
commit a813433891
73 changed files with 4697 additions and 453 deletions

View File

@@ -26,6 +26,13 @@ import { useDictionary } from './I18nProvider';
// client component cannot import.
import { resolveWatermarkOpacity } from '@/lib/config/schema';
import { formatCamera } from '@/lib/exif';
import { buildPhotoPermalink } from '@/lib/photoHash';
import { nextSlideshowSpeed, type SlideshowSpeed } from '@/lib/slideshow';
import {
LIGHTBOX_SHORTCUTS,
lightboxActionFor,
shortcutDisplayKeys,
} from '@/lib/lightboxShortcuts';
export interface LightboxWatermark {
enabled?: boolean;
@@ -64,6 +71,20 @@ export function Lightbox({
const [canFullscreen, setCanFullscreen] = useState(false);
const { exifData, exifLoading, fetchExif, clearExif } = useExif();
const [imageLoaded, setImageLoaded] = useState(false);
/**
* 'manual' is the honest outcome when the clipboard is unavailable — which is
* not a rarity here: `navigator.clipboard` is undefined outside a secure
* context, and a self-hosted portfolio reached over plain http on a LAN is
* exactly that. The link is then shown for the visitor to copy by hand
* rather than the button appearing to do nothing.
*/
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'manual'>('idle');
/**
* Slideshow interval in seconds, or null for off. `s` cycles through the
* three speeds and back to off, so the feature needs no configuration and no
* control of its own — in keeping with how the viewer treats its other keys.
*/
const [slideshowSeconds, setSlideshowSeconds] = useState<SlideshowSpeed>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const closeBtnRef = useRef<HTMLButtonElement>(null);
@@ -107,6 +128,81 @@ export function Lightbox({
setShowShortcuts((open) => !open);
}, []);
/**
* The deep link to the photo on screen.
*
* Built from the current index rather than read back from `location.hash`,
* so it does not depend on the grid's hash-sync effect having run yet. The
* `#photo-N` form is the permalink the grid already writes and restores;
* this only supplies the affordance to copy it (#478).
*
* The link is positional, as `gallery.yaml.example` documents: reordering an
* album moves where a shared link lands. That is a property of the existing
* permalink, not of the button.
*/
const permalink = useCallback(() => {
if (typeof window === 'undefined') return '';
return buildPhotoPermalink(window.location, currentIndex);
}, [currentIndex]);
const handleCopyLink = useCallback(() => {
const url = permalink();
if (!navigator.clipboard) {
setCopyState('manual');
return;
}
navigator.clipboard.writeText(url).then(
() => setCopyState('copied'),
() => setCopyState('manual'),
);
}, [permalink]);
/**
* Auto-advance, so a gallery can run unattended at an exhibition, a fair
* booth or on a second screen (#473).
*
* `onNext` already wraps at the end of the album, so the sequence loops on
* its own with nothing further to arrange.
*/
const cycleSlideshow = useCallback(() => {
setSlideshowSeconds((current) => nextSlideshowSpeed(current));
}, []);
useEffect(() => {
if (slideshowSeconds === null) return;
const timer = setInterval(onNext, slideshowSeconds * 1000);
return () => clearInterval(timer);
}, [slideshowSeconds, onNext]);
/*
* Any deliberate move through the album stops the slideshow. Someone
* reaching for an arrow has taken over; leaving the timer running would
* yank the photo away from under them a second later.
*
* The timer above keeps calling the raw `onNext`, so it does not stop
* itself.
*/
const manualNext = useCallback(() => {
setSlideshowSeconds(null);
onNext();
}, [onNext]);
const manualPrev = useCallback(() => {
setSlideshowSeconds(null);
onPrev();
}, [onPrev]);
// A confirmation must not outlive the photo it was about.
useEffect(() => {
setCopyState('idle');
}, [currentIndex]);
useEffect(() => {
if (copyState !== 'copied') return;
const timer = setTimeout(() => setCopyState('idle'), 2000);
return () => clearTimeout(timer);
}, [copyState]);
/*
* 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
@@ -171,8 +267,8 @@ export function Lightbox({
}, [currentIndex, assets]);
const { handleTouchStart, handleTouchEnd } = useSwipe({
onSwipeLeft: onNext,
onSwipeRight: onPrev,
onSwipeLeft: manualNext,
onSwipeRight: manualPrev,
});
/*
@@ -183,8 +279,29 @@ export function Lightbox({
*/
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'Escape':
/*
* Bare keys only. The manual copy fallback puts a real text field on
* screen, and a visitor pressing Cmd/Ctrl+C in it means the browser's
* copy, not this viewer's — swallowing it would break the one gesture
* that field exists for. Esc stays available so the viewer can always
* be left.
*/
const target = e.target as HTMLElement | null;
const inTextField = target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA';
if (e.key !== 'Escape' && (e.metaKey || e.ctrlKey || e.altKey || inTextField)) return;
/*
* Which keys exist is the catalogue's business, not this handler's — see
* lib/lightboxShortcuts.ts. A key it does not list never gets here, and
* the switch below is exhaustive over LightboxAction, so an action added
* there without a branch here fails to compile rather than silently
* doing nothing.
*/
const action = lightboxActionFor(e.key);
if (action === null) return;
switch (action) {
case 'close':
// 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
@@ -195,29 +312,41 @@ export function Lightbox({
else if (document.fullscreenElement) void document.exitFullscreen().catch(() => {});
else onClose();
break;
case 'ArrowRight':
onNext();
case 'next':
manualNext();
break;
case 'ArrowLeft':
onPrev();
case 'prev':
manualPrev();
break;
case 'i':
case 'I':
case 'info':
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':
case 'slideshow':
e.preventDefault();
cycleSlideshow();
break;
case 'download':
if (current?.downloadUrl) {
e.preventDefault();
window.location.href = current.downloadUrl;
}
break;
case 'copyLink':
e.preventDefault();
handleCopyLink();
break;
case 'shortcutList':
toggleShortcuts();
break;
case 'f':
case 'F':
case 'fullscreen':
if (canFullscreen) toggleFullscreen();
break;
default: {
// Exhaustiveness guard: this only type-checks while every
// LightboxAction has a branch above.
const unhandled: never = action;
void unhandled;
}
}
};
@@ -230,10 +359,13 @@ export function Lightbox({
};
}, [
canFullscreen,
current,
cycleSlideshow,
handleCopyLink,
handleExifToggle,
onClose,
onNext,
onPrev,
manualNext,
manualPrev,
showExifToggle,
showShortcuts,
toggleFullscreen,
@@ -251,26 +383,27 @@ export function Lightbox({
);
/*
* 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.
* The advertised shortcuts, resolved from the shared catalogue.
*
* The set of keys lives in lib/lightboxShortcuts.ts because the admin help
* renders the same list; only the two labels that depend on current state
* are decided here.
*/
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 shortcutRows = LIGHTBOX_SHORTCUTS.filter((shortcut) => {
if (shortcut.availability === 'exifPanel') return showExifToggle;
if (shortcut.availability === 'fullscreen') return canFullscreen;
if (shortcut.availability === 'download') return Boolean(current?.downloadUrl);
return true;
}).map((shortcut) => {
let label: string = t.lightbox[shortcut.labelKey];
if (shortcut.labelKey === 'shortcutFullscreen' && isFullscreen) {
label = t.lightbox.shortcutExitFullscreen;
}
if (shortcut.labelKey === 'shortcutSlideshow' && slideshowSeconds !== null) {
label = t.lightbox.shortcutSlideshowRunning(slideshowSeconds);
}
return { keys: shortcutDisplayKeys(shortcut), label };
});
const lightboxJsx = (
<div
@@ -308,7 +441,7 @@ export function Lightbox({
{/* Previous button */}
<button
className={`${styles.nav} ${styles.navPrev}`}
onClick={onPrev}
onClick={manualPrev}
aria-label={t.lightbox.previous}
title={t.lightbox.previousTitle}
>
@@ -354,7 +487,7 @@ export function Lightbox({
<img
className={`${styles.image}${imageLoaded ? ` ${styles.imageLoaded}` : ''}`}
src={current.previewUrl}
alt=""
alt={current.caption ?? ''}
draggable={false}
onLoad={() => setImageLoaded(true)}
onError={() => console.error(`[Lightbox] Failed to load image: ${current.previewUrl}`)}
@@ -380,7 +513,7 @@ export function Lightbox({
{/* Next button */}
<button
className={`${styles.nav} ${styles.navNext}`}
onClick={onNext}
onClick={manualNext}
aria-label={t.lightbox.next}
title={t.lightbox.nextTitle}
>
@@ -397,45 +530,142 @@ export function Lightbox({
</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>
{/*
Slideshow state, announced but not drawn: a badge over a photograph
would be paid for by every visitor, and the shortcut list already
carries the speed for anyone who wants to see it.
*/}
<p className="sr-only" role="status">
{slideshowSeconds === null
? t.lightbox.slideshowStopped
: t.lightbox.slideshowRunning(slideshowSeconds)}
</p>
{/*
One bottom bar rather than five separately anchored controls.
They used to carry hard-coded offsets, which broke in two ways: the
info button grows when its label becomes "Hide info" and walked into
the favourite button, and the left-hand controls ran into the centred
counter on a narrow viewport. A flex row cannot collide with itself.
The bar itself ignores pointer events so it does not swallow clicks
on the photograph behind it; each group takes them back.
*/}
<div className={styles.bottomBar}>
<div className={`${styles.bottomBarGroup} ${styles.bottomBarLeft}`}>
{/* Copy link — bottom left, opposite the info toggle */}
<button
className={styles.infoToggle}
onClick={handleCopyLink}
aria-label={t.lightbox.copyLink}
title={t.lightbox.copyLinkTitle}
>
<svg
aria-hidden="true"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
{copyState === 'copied' ? t.lightbox.copied : t.lightbox.copyLinkShort}
</button>
{/* Download the original — only when the album offers it (#475) */}
{current?.downloadUrl && (
<a
className={`${styles.infoToggle} ${styles.downloadToggle}`}
href={current.downloadUrl}
download
aria-label={t.lightbox.download}
title={t.lightbox.downloadTitle}
>
<svg
aria-hidden="true"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
{t.lightbox.downloadShort}
</a>
)}
</div>
{/* 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>
<div className={`${styles.bottomBarGroup} ${styles.bottomBarRight}`}>
{/* Proofing favorite button */}
{proofing && current && (
<button
className={styles.infoToggle}
style={{
color: isFav ? '#ff4d4f' : 'inherit',
fontWeight: isFav ? 600 : 400,
}}
onClick={() => proofing.toggleFavorite(current.id)}
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 ? t.proofing.saved : t.proofing.favorite}
</button>
)}
{/* EXIF toggle */}
{showExifToggle && (
<button
className={styles.infoToggle}
onClick={handleExifToggle}
aria-expanded={showExif}
aria-controls="exif-panel"
aria-label={t.lightbox.toggleInfo}
title={t.lightbox.toggleInfoTitle}
>
{showExif ? t.lightbox.hideInfo : t.lightbox.info}
</button>
)}
</div>
</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 ? 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 ? t.proofing.saved : t.proofing.favorite}
</button>
)}
{/* EXIF toggle */}
{showExifToggle && (
<button
className={styles.infoToggle}
onClick={handleExifToggle}
aria-expanded={showExif}
aria-controls="exif-panel"
aria-label={t.lightbox.toggleInfo}
title={t.lightbox.toggleInfoTitle}
>
{showExif ? t.lightbox.hideInfo : t.lightbox.info}
</button>
{/* Clipboard unavailable — show the link rather than fail quietly */}
{copyState === 'manual' && (
<div className={styles.copyManual} role="status">
<label className={styles.copyManualLabel} htmlFor="lightbox-permalink">
{t.lightbox.copyManual}
</label>
<input
id="lightbox-permalink"
className={styles.copyManualInput}
type="text"
readOnly
autoFocus
value={permalink()}
onFocus={(e) => e.currentTarget.select()}
/>
</div>
)}
{/* EXIF panel */}