release: v0.16.0

See CHANGELOG.md for the full v0.16.0 entry.

---------

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>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
This commit is contained in:
Ralksta
2026-09-22 14:12:13 +02:00
committed by GitHub
parent c3f92ca4ec
commit a3a6319c29
120 changed files with 6818 additions and 975 deletions

View File

@@ -2,8 +2,11 @@
* PhotoGrid — client component wrapping the masonry grid and lightbox.
* Handles image click → lightbox open, keyboard nav, and EXIF fetching.
*
* Deep-link support: syncs lightbox index with the URL hash (#photo-N).
* Sharing a link with a hash opens the lightbox to that photo directly.
* Deep-link support: syncs the lightbox with `?photo=<assetId>`, a stable
* per-photo id rather than a position, so reordering or deleting other
* photos in the album does not repoint an already-shared link (#588). A
* legacy `#photo-N` hash is still read on the initial load — those links
* have been shared for months — but the grid never writes that form again.
*/
'use client';
@@ -15,7 +18,7 @@ import { FadeIn } from '@/components/FadeIn';
import { ProofingProvider, useProofing } from '@/components/ProofingContext';
import { ProofingModal } from '@/components/ProofingModal';
import { useDictionary } from '@/components/I18nProvider';
import { parsePhotoHash, buildPhotoHash } from '@/lib/photoHash';
import { parsePhotoHash, parsePhotoQuery, buildPhotoQuery } from '@/lib/photoHash';
export interface PhotoItem {
id: string;
@@ -77,49 +80,73 @@ function PhotoGridInner({
return assets;
}, [assets, proofing]);
// ── Initial hash check (Client-only to avoid hydration mismatch) ──
// ── Initial deep-link check (client-only to avoid hydration mismatch) ──
// ?photo=<assetId> wins when present; #photo-N is read only as a fallback,
// for a link shared before this format existed.
useEffect(() => {
const token = parsePhotoQuery(window.location.search);
const byToken = token ? displayedAssets.findIndex((a) => a.id === token) : -1;
if (byToken >= 0) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLightboxIndex(byToken);
return;
}
const idx = parsePhotoHash(window.location.hash);
if (idx !== null && idx < displayedAssets.length) {
// state from URL hash on mount only
// eslint-disable-next-line react-hooks/set-state-in-effect
setLightboxIndex(idx);
}
}, [displayedAssets.length]);
}, [displayedAssets]);
// ── Sync URL hash when lightbox state changes ─────────────────
// ── Sync the URL's `photo` param when the lightbox state changes ──
useEffect(() => {
if (lightboxIndex !== null && lightboxIndex < displayedAssets.length) {
const hash = buildPhotoHash(lightboxIndex);
if (window.location.hash !== hash) {
window.history.replaceState(null, '', hash);
}
const assetId =
lightboxIndex !== null && lightboxIndex < displayedAssets.length
? displayedAssets[lightboxIndex].id
: null;
if (assetId === null) return;
const query = buildPhotoQuery(window.location.search, assetId);
// Building the full URL (not just the search string) drops any leftover
// #photo-N hash from a legacy link this page was opened with.
const url = window.location.pathname + query;
if (window.location.pathname + window.location.search !== url || window.location.hash) {
window.history.replaceState(null, '', url);
}
}, [lightboxIndex, displayedAssets.length]);
}, [lightboxIndex, displayedAssets]);
// ── Listen for browser back/forward (hash change) ─────────────
// ── Listen for browser back/forward ────────────────────────────
useEffect(() => {
const handleHashChange = () => {
const idx = parsePhotoHash(window.location.hash);
if (idx !== null && idx < displayedAssets.length) {
setLightboxIndex(idx);
} else {
setLightboxIndex(null);
}
const handlePopState = () => {
const token = parsePhotoQuery(window.location.search);
const byToken = token ? displayedAssets.findIndex((a) => a.id === token) : -1;
setLightboxIndex(byToken >= 0 ? byToken : null);
};
window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
}, [displayedAssets.length]);
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [displayedAssets]);
const openLightbox = useCallback((index: number) => {
setLightboxIndex(index);
window.history.pushState(null, '', buildPhotoHash(index));
}, []);
const openLightbox = useCallback(
(index: number) => {
setLightboxIndex(index);
const asset = displayedAssets[index];
if (asset) {
window.history.pushState(
null,
'',
window.location.pathname + buildPhotoQuery(window.location.search, asset.id),
);
}
},
[displayedAssets],
);
const closeLightbox = useCallback(() => {
setLightboxIndex(null);
window.history.pushState(null, '', window.location.pathname + window.location.search);
window.history.pushState(
null,
'',
window.location.pathname + buildPhotoQuery(window.location.search, null),
);
}, []);
const goNext = useCallback(() => {

View File

@@ -3,6 +3,7 @@ import Image from 'next/image';
import { imageUrl } from '@/lib/urls';
import { SubpageSectionConfig } from '@/lib/config';
import { getServerDictionary } from '@/lib/i18n/server';
import { BackLink } from '@/components/BackLink';
interface SubpageAlbum {
id: string;
@@ -30,6 +31,8 @@ interface SubpageGridViewProps {
index?: number;
/** `--subpage-columns` / `--subpage-gap` from the resolved grid config. */
gridStyle?: React.CSSProperties;
/** The subpage that follows this one in the nav/homepage order, if any (#591). */
nextSubpage?: { slug: string; name: string };
}
const pad2 = (n: number) => String(n).padStart(2, '0');
@@ -120,6 +123,7 @@ export function SubpageGridView({
sections,
index,
gridStyle,
nextSubpage,
}: SubpageGridViewProps) {
// Build lookup maps once
const albumMap = new Map(albums.map((a) => [a.id, a]));
@@ -131,22 +135,23 @@ export function SubpageGridView({
return (
<div className="subpage-container">
{(title || subtitle) && (
<header className="subpage-header">
<div className="subpage-header__main">
{index !== undefined && (
<p className="subpage-header__kicker" aria-hidden="true">
{t.subpage.collectionKicker(pad2(index))}
</p>
)}
{title && <h1 className="subpage-title">{title}</h1>}
{subtitle && <p className="subpage-subtitle">{subtitle}</p>}
</div>
<header className="subpage-header">
<div className="subpage-header__main">
<BackLink href="/" label={t.common.backToGallery} />
{index !== undefined && (
<p className="subpage-header__kicker" aria-hidden="true">
{t.subpage.collectionKicker(pad2(index))}
</p>
)}
{title && <h1 className="subpage-title">{title}</h1>}
{subtitle && <p className="subpage-subtitle">{subtitle}</p>}
</div>
{(title || subtitle) && (
<p className="subpage-header__meta" aria-hidden="true">
{t.common.albums(albums.length)} · {photoCount(totalPhotos)}
</p>
</header>
)}
)}
</header>
{/* Typographic Table of Contents */}
{hasSections && (
@@ -194,6 +199,35 @@ export function SubpageGridView({
gridStyle={gridStyle}
/>
)}
{/* Onward navigation to the next subpage in nav order (#591) — a subpage
grid otherwise dead-ends into the browser's back button. */}
{nextSubpage && (
<nav className="album-nav" aria-label={t.subpage.nextSubpageAria(nextSubpage.name)}>
<span aria-hidden="true" />
<Link
href={`/${nextSubpage.slug}`}
className="album-nav__link album-nav__link--next"
aria-label={t.subpage.nextSubpageAria(nextSubpage.name)}
>
<span className="album-nav__text">
<span className="album-nav__kicker">{t.subpage.nextSubpage}</span>
<span className="album-nav__name">{nextSubpage.name}</span>
</span>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</Link>
</nav>
)}
</div>
);
}

View File

@@ -25,7 +25,7 @@ import {
downloadUrl,
assetAspectRatio,
} from '@/lib/urls';
import { encodeAssetId } from '@/lib/tokens';
import { encodeAssetId, decodeAssetId } from '@/lib/tokens';
import {
buildCoverGridVars,
getConfig,
@@ -34,7 +34,7 @@ import {
resolveProofing,
type GridConfig,
} from '@/lib/config';
import { isProtected, isAuthenticated } from '@/lib/auth';
import { isProtected, isAuthenticated, withoutLockedAlbums } from '@/lib/auth';
import { isAdminAuthenticated } from '@/lib/admin/auth';
import PasswordGate from '@/components/PasswordGate';
import { AdminDiagnosticBanner } from '@/components/AdminDiagnosticBanner';
@@ -56,7 +56,21 @@ interface PathPageProps {
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;
}
export async function generateMetadata({ params }: PathPageProps): Promise<Metadata> {
/**
* Whether `key` is password-protected and this request has not unlocked it.
* generateMetadata runs unconditionally — unlike the page body, nothing
* downstream of it stops a real title, photo count or cover image from
* reaching an unauthenticated `<head>` unless this is checked first
* (GHSA-fvgv-97g3-wjr7).
*/
async function isLocked(key: string, type: 'subpage' | 'album'): Promise<boolean> {
if (!isProtected(key, type)) return false;
const cookieStore = await cookies();
const getCookie = (name: string) => cookieStore.get(name)?.value;
return !isAuthenticated(key, getCookie, type);
}
export async function generateMetadata({ params, searchParams }: PathPageProps): Promise<Metadata> {
// Next hands catch-all segments over percent-encoded, so a non-ASCII slug
// ("/家族相册") would never match a stored one. Decode once, here, and every
// comparison downstream works on the same form (#522).
@@ -64,58 +78,80 @@ export async function generateMetadata({ params }: PathPageProps): Promise<Metad
const path = rawPath?.map(normalizeSlug);
if (!path || path.length === 0) return {};
// A shared photo link's whole point is that it reaches the server — unlike
// the #photo-N hash it replaces, a `photo` query param is visible here, so
// a link-preview bot can render the photo itself rather than the album's
// generic card (#588).
const sp = (await searchParams) || {};
const photoToken = typeof sp.photo === 'string' ? sp.photo : undefined;
const photoAssetId = photoToken ? decodeAssetId(photoToken) : null;
let photoAsset: ImmichAsset | undefined;
const slug = path[0];
let title = slug;
let subtitle = '';
let description: string | undefined = undefined;
if (path.length === 1 && immich.isSubpageSlug(slug)) {
const subpageLocked = await isLocked(slug, 'subpage');
const result = await immich.getSubpageAlbums(slug);
if (result) {
if (result.subpage.subtitle) {
if (!subpageLocked && result.subpage.subtitle) {
description = result.subpage.subtitle;
}
if (result.albums.length === 1) {
const album = await immich.getAlbumBySlug(result.albums[0].slug, slug);
if (album) {
if (album && !subpageLocked && !(await isLocked(album.id, 'album'))) {
title = album.albumName;
const count = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length;
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
}
} else {
} else if (!subpageLocked) {
title = result.subpage.title || result.subpage.name;
}
}
} else if (path.length === 2) {
const album = await immich.getAlbumBySlug(path[1], slug);
if (album) {
if (album && !(await isLocked(slug, 'subpage')) && !(await isLocked(album.id, 'album'))) {
title = album.albumName;
const count = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length;
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
}
} else {
const album = await immich.getAlbumBySlug(slug);
if (album) {
if (album && !(await isLocked(album.id, 'album'))) {
title = album.albumName;
const count = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length;
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
}
}
const ogUrl = `/api/og?title=${encodeURIComponent(title)}${subtitle ? `&subtitle=${encodeURIComponent(subtitle)}` : ''}`;
// The title stays the album's — it's still the context a reader wants —
// but the description prefers the photo's own caption, and the image is
// the photo itself rather than the generated text card.
const ogDescription =
photoAsset?.exifInfo?.description?.trim() ||
description ||
(subtitle ? `${title} — ${subtitle}` : undefined);
const ogImage = photoAsset
? imageUrl(photoAsset.id, 'preview')
: `/api/og?title=${encodeURIComponent(title)}${subtitle ? `&subtitle=${encodeURIComponent(subtitle)}` : ''}`;
return {
title,
description: description || (subtitle ? `${title} — ${subtitle}` : undefined),
description: ogDescription,
openGraph: {
title,
description: description || (subtitle ? `${title} — ${subtitle}` : undefined),
images: [ogUrl],
description: ogDescription,
images: [ogImage],
},
twitter: {
card: 'summary_large_image',
title,
description: description || (subtitle ? `${title} — ${subtitle}` : undefined),
images: [ogUrl],
description: ogDescription,
images: [ogImage],
},
};
}
@@ -229,7 +265,13 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
const { path: rawPath } = await params;
const path = rawPath?.map(normalizeSlug);
const sParams = (await searchParams) || {};
const forceFresh = sParams.fresh === '1' || sParams.preview === 'true';
// ?fresh=1 / ?preview=true forces a cache-bypassing Immich refetch — cheap
// to trigger, and every subpage request fans out into an album-list fetch
// plus paged metadata search calls per album. Gated to admins so it cannot
// be used to force-refresh (or, before the immich.ts fix alongside this,
// to empty) the shared cache from an unauthenticated request.
const forceFresh =
(sParams.fresh === '1' || sParams.preview === 'true') && (await isAdminAuthenticated());
const config = getConfig();
@@ -382,9 +424,13 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
? loadEssayFromFile(result.subpage.essayFile)
: null;
// Fetch assets from all subpage albums
// Fetch assets from the subpage's albums. An essay has no per-album
// gate to pass through, so an album with its own password stays out
// until it has been unlocked.
const cookieStore = await cookies();
const openAlbums = withoutLockedAlbums(albums, (name) => cookieStore.get(name)?.value);
const allAlbums = await Promise.all(
albums.map((a) => immich.getAlbumBySlug(a.slug, slug, forceFresh)),
openAlbums.map((a) => immich.getAlbumBySlug(a.slug, slug, forceFresh)),
);
const allAssets = allAlbums.flatMap((a) => (a ? a.assets : []));
const images = toPhotoItems(
@@ -400,7 +446,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
title: result.subpage.title || result.subpage.name,
subtitle: result.subpage.subtitle,
},
blocks: albums.flatMap((a) => [
blocks: openAlbums.flatMap((a) => [
{ type: 'heading' as const, level: 2, text: a.albumName },
...a.assets.map((asset) => ({
type: 'photo' as const,
@@ -508,6 +554,15 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
const enabledSubpages = config.subpages.filter((sp) => sp.enabled !== false);
const subpageIndex = enabledSubpages.findIndex((sp) => sp.slug === slug);
// The subpage that follows this one in the same list the nav/homepage
// build from — hidden and disabled subpages are already excluded there,
// so "next" never points somewhere the visitor could not otherwise reach
// (#591).
const navSubpages = await immich.getSubpages(forceFresh);
const navIndex = navSubpages.findIndex((sp) => sp.slug === slug);
const nextSubpage =
navIndex >= 0 && navIndex < navSubpages.length - 1 ? navSubpages[navIndex + 1] : undefined;
return (
<SubpageGridView
slug={slug}
@@ -518,6 +573,9 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
sections={result.subpage.sections}
gridStyle={buildCoverGridStyle(result.subpage.coverGrid)}
{...(subpageIndex >= 0 ? { index: subpageIndex + 1 } : {})}
{...(nextSubpage
? { nextSubpage: { slug: nextSubpage.slug, name: nextSubpage.name } }
: {})}
/>
);
}