774 lines
31 KiB
TypeScript
774 lines
31 KiB
TypeScript
/**
|
|
* Catch-all route — handles both subpage listings and album detail pages.
|
|
*
|
|
* Single segment:
|
|
* - If slug matches a subpage → render subpage album grid
|
|
* - If slug matches a content page (content/pages/<slug>.md) → render it
|
|
* - If slug matches a standalone album → render album detail
|
|
*
|
|
* Two segments:
|
|
* - Treat as subpage-slug/album-slug → render album detail
|
|
* with back-link to the subpage
|
|
*
|
|
* Three or more segments: 404.
|
|
*/
|
|
|
|
import { Suspense } from 'react';
|
|
import { cookies } from 'next/headers';
|
|
import type { Metadata } from 'next';
|
|
import { immich, type ImmichAsset } from '@/lib/immich';
|
|
import { notFound } from 'next/navigation';
|
|
import { toPhotoItems } from './photoItems';
|
|
import { imageUrl, assetPlaceholder, archiveUrl } from '@/lib/urls';
|
|
import { ogImageUrl } from '@/lib/ogImage';
|
|
import { encodeAssetId, decodeAssetId } from '@/lib/tokens';
|
|
import { encodeEmail } from '@/lib/emailObfuscation';
|
|
import {
|
|
buildCoverGridVars,
|
|
getConfig,
|
|
hasExifPanelContent,
|
|
normalizeSlug,
|
|
resolveProofing,
|
|
resolveZoom,
|
|
type GridConfig,
|
|
} from '@/lib/config';
|
|
import { isProtected, isAuthenticated, withoutLockedAlbums } from '@/lib/auth';
|
|
import { isAdminAuthenticated } from '@/lib/admin/auth';
|
|
import PasswordGate from '@/components/PasswordGate';
|
|
import { AdminDiagnosticBanner } from '@/components/AdminDiagnosticBanner';
|
|
import { AlbumDetailView } from './AlbumDetailView';
|
|
import { albumNeighbours } from '@/lib/albumNav';
|
|
import { albumStructuredData } from '@/lib/structuredData';
|
|
import { absoluteUrl } from '@/lib/siteUrl';
|
|
import { SubpageGridView } from './SubpageGridView';
|
|
import { EssayView } from './EssayView';
|
|
import { parseEssayMarkdown, type EssayBlock, type ParsedEssay } from '@/lib/essay';
|
|
import { pinsForEntry } from '@/lib/journalMap';
|
|
import { expandAlbumBlocks } from '@/lib/journalAlbum';
|
|
import { mapBlockAssetIds } from '@/lib/journal';
|
|
import { strictestPrecision, type LocationPrecision } from '@/lib/mapPrecision';
|
|
import { resolveEssayFile, generatedEssayCaption } from '@/lib/essaySource';
|
|
import { getServerDictionary } from '@/lib/i18n/server';
|
|
import { contentPageMetadata, renderContentPage } from './contentPage';
|
|
import { loadAlbumBlocks } from './essayPayload';
|
|
import { isLocked, routeExists } from './routeExists';
|
|
import { PathSkeleton } from './PathSkeleton';
|
|
|
|
// Render at request time — requires live Immich connection
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
interface PathPageProps {
|
|
params: Promise<{ path: string[] }>;
|
|
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;
|
|
}
|
|
|
|
/**
|
|
* Metadata for a path that names nothing this site publishes. The title is
|
|
* the dictionary's, never the requested slug: generateMetadata runs for any
|
|
* URL a visitor types, and echoing it would put their text in the `<title>`
|
|
* and, through a signed share card, in an image served from this domain.
|
|
* Leaving `openGraph` out keeps the root layout's site card. `robots: null`
|
|
* drops the layout's `index, follow`, which would otherwise contradict the
|
|
* `noindex` Next adds to every not-found response.
|
|
*/
|
|
function notFoundMetadata(): Metadata {
|
|
return { title: getServerDictionary().error.notFoundTitle, robots: null };
|
|
}
|
|
|
|
/** Metadata for a path behind a password this request has not given. */
|
|
function lockedMetadata(): Metadata {
|
|
return { title: getServerDictionary().password.protectedPage, robots: { index: false } };
|
|
}
|
|
|
|
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).
|
|
const { path: rawPath } = await params;
|
|
const path = rawPath?.map(normalizeSlug);
|
|
// Deeper paths are a 404 (see the page below); they name nothing to describe.
|
|
if (!path || path.length === 0 || path.length > 2) return notFoundMetadata();
|
|
|
|
// 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;
|
|
|
|
// Each branch follows the page body's order of gates and 404s, and takes
|
|
// the title only from site-authored text (an album or subpage name). A
|
|
// branch that finds nothing, or finds it locked, returns early, so the
|
|
// requested slug never becomes a title or share-card text. isLocked() comes
|
|
// first because generateMetadata runs unconditionally: nothing downstream
|
|
// stops a real title, photo count or cover image from reaching an
|
|
// unauthenticated `<head>` otherwise (GHSA-fvgv-97g3-wjr7).
|
|
const slug = path[0];
|
|
const t = getServerDictionary();
|
|
let title: string;
|
|
let subtitle = '';
|
|
let description: string | undefined = undefined;
|
|
const photoCount = (assets: ImmichAsset[]) =>
|
|
t.common.photos(assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length);
|
|
|
|
if (path.length === 1 && immich.isSubpageSlug(slug)) {
|
|
if (await isLocked(slug, 'subpage')) return lockedMetadata();
|
|
const result = await immich.getSubpageAlbums(slug);
|
|
if (!result || result.albums.length === 0) return notFoundMetadata();
|
|
if (result.subpage.subtitle) description = result.subpage.subtitle;
|
|
if (result.albums.length === 1) {
|
|
const album = await immich.getAlbumBySlug(result.albums[0].slug, slug);
|
|
if (!album) return notFoundMetadata();
|
|
if (await isLocked(album.id, 'album')) return lockedMetadata();
|
|
title = album.albumName;
|
|
subtitle = photoCount(album.assets);
|
|
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
|
|
} else {
|
|
title = result.subpage.title || result.subpage.name;
|
|
// A cover grid has no photo count to describe it by; without this it
|
|
// inherited the site-wide description.
|
|
description ??= `${title} — ${t.common.albums(result.albums.length)}`;
|
|
}
|
|
} else if (path.length === 2) {
|
|
if (await isLocked(slug, 'subpage')) return lockedMetadata();
|
|
const album = await immich.getAlbumBySlug(path[1], slug);
|
|
if (!album) return notFoundMetadata();
|
|
if (await isLocked(album.id, 'album')) return lockedMetadata();
|
|
title = album.albumName;
|
|
subtitle = photoCount(album.assets);
|
|
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
|
|
} else {
|
|
const pageMeta = await contentPageMetadata(slug);
|
|
if (pageMeta) return pageMeta;
|
|
const album = await immich.getAlbumBySlug(slug);
|
|
if (!album) return notFoundMetadata();
|
|
if (await isLocked(album.id, 'album')) return lockedMetadata();
|
|
title = album.albumName;
|
|
subtitle = photoCount(album.assets);
|
|
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
|
|
}
|
|
|
|
// 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, 'preview') : ogImageUrl(title, subtitle);
|
|
|
|
// A hidden subpage is reachable by link only: out of the nav, the home page
|
|
// and the sitemap. Indexing it, or an album below it, would undo that.
|
|
const config = getConfig();
|
|
const hidden =
|
|
(path.length === 2 || immich.isSubpageSlug(slug)) &&
|
|
config.subpages.some((sp) => sp.slug === slug && sp.hidden === true);
|
|
|
|
return {
|
|
title,
|
|
description: ogDescription,
|
|
...(hidden ? { robots: { index: false, follow: !config.seo.noFollow } } : {}),
|
|
openGraph: {
|
|
title,
|
|
description: ogDescription,
|
|
images: [ogImage],
|
|
},
|
|
twitter: {
|
|
card: 'summary_large_image',
|
|
title,
|
|
description: ogDescription,
|
|
images: [ogImage],
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* JSON-LD for an album page. Null unless a site URL is configured — structured
|
|
* data is a set of claims about absolute URLs, and there is nothing truthful to
|
|
* claim without one (#472).
|
|
*/
|
|
function structuredDataFor(
|
|
album: {
|
|
albumName: string;
|
|
description?: string;
|
|
albumThumbnailAssetId?: string | null;
|
|
assets?: ImmichAsset[];
|
|
},
|
|
path: string,
|
|
photoCount: number,
|
|
) {
|
|
const config = getConfig();
|
|
const coverId = album.albumThumbnailAssetId;
|
|
// The album's own copy of its cover, when it has one, so an edit made in
|
|
// Immich reaches the URL (#831).
|
|
const coverAsset = coverId ? album.assets?.find((a) => a.id === coverId) : undefined;
|
|
const cover = coverId
|
|
? absoluteUrl(config.siteUrl, imageUrl(coverAsset ?? coverId, 'preview'))
|
|
: null;
|
|
return albumStructuredData({
|
|
siteUrl: config.siteUrl,
|
|
pageUrl: absoluteUrl(config.siteUrl, path),
|
|
albumName: album.albumName,
|
|
...(album.description ? { description: album.description } : {}),
|
|
coverUrl: cover,
|
|
...(config.legal.name?.trim() ? { creator: config.legal.name } : {}),
|
|
...(config.seo.license ? { license: config.seo.license } : {}),
|
|
photoCount,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns a PasswordGate element if the key (slug or ID) is protected and
|
|
* not yet authenticated, otherwise returns null.
|
|
*/
|
|
async function gateIfProtected(
|
|
key: string,
|
|
type: 'subpage' | 'album' = 'subpage',
|
|
titleOverride?: string,
|
|
): Promise<React.ReactElement | null> {
|
|
if (!isProtected(key, type)) return null;
|
|
const cookieStore = await cookies();
|
|
const getCookie = (name: string) => cookieStore.get(name)?.value;
|
|
if (isAuthenticated(key, getCookie, type)) return null;
|
|
|
|
let title = titleOverride || key;
|
|
if (!titleOverride && type === 'subpage') {
|
|
const subpageData = await immich.getSubpageAlbums(key);
|
|
const sp = subpageData?.subpage;
|
|
title = sp ? sp.title || sp.name : key;
|
|
}
|
|
|
|
return <PasswordGate slug={key} title={title} type={type} />;
|
|
}
|
|
|
|
/** Fetch hero image URL + blur placeholder for an album if configured. */
|
|
async function getAlbumHeroData(
|
|
albumId: string,
|
|
config: ReturnType<typeof getConfig>,
|
|
): Promise<{ heroImageUrl: string; heroBlurDataURL?: string } | null> {
|
|
const heroAssetId = config.albumHeroImages[albumId];
|
|
if (!heroAssetId) return null;
|
|
const asset = await immich.getAssetInfo(heroAssetId);
|
|
const ph = asset ? assetPlaceholder(asset) : null;
|
|
return {
|
|
heroImageUrl: imageUrl(asset ?? heroAssetId, 'preview'),
|
|
heroBlurDataURL: ph?.blurDataURL,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Decides whether the path exists before anything streams, then renders the
|
|
* content behind the skeleton.
|
|
*
|
|
* The order is the point. Next sends the status with the first byte, and the
|
|
* first byte goes out as soon as a Suspense fallback renders. A notFound()
|
|
* thrown after that can only add a noindex tag to a 200 (a "soft 404"), which
|
|
* is what every unknown slug got while this page sat inside a loading.tsx
|
|
* boundary. routeExists() needs only the config and the album list the header
|
|
* nav already waited for, so the check costs no time; the album's photos, the
|
|
* slow part, still load behind the skeleton.
|
|
*
|
|
* PathContent keeps its own notFound() calls as the backstop for what the
|
|
* check cannot see, such as an album removed from Immich since the list was
|
|
* cached. Those are still soft.
|
|
*/
|
|
export default async function PathPage(props: PathPageProps) {
|
|
const { path: rawPath } = await props.params;
|
|
if (!(await routeExists(rawPath?.map(normalizeSlug)))) notFound();
|
|
|
|
return (
|
|
<Suspense fallback={<PathSkeleton />}>
|
|
<PathContent {...props} />
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
async function PathContent({ params, searchParams }: PathPageProps) {
|
|
// 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).
|
|
const { path: rawPath } = await params;
|
|
const path = rawPath?.map(normalizeSlug);
|
|
const sParams = (await searchParams) || {};
|
|
// ?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();
|
|
|
|
// The proofing dialog's "email to photographer" needs a recipient: the
|
|
// configured `proofing.email`, else the footer contact email (#736). Encoded
|
|
// here so the plain address stays out of the HTML and the RSC payload, like
|
|
// every other address the site renders.
|
|
const encodedMailto = config.proofing.email ? encodeEmail(config.proofing.email) : undefined;
|
|
|
|
// Build grid CSS custom properties, optionally merging subpage overrides
|
|
const buildGridStyle = (overrides?: Partial<GridConfig>): React.CSSProperties => {
|
|
const g = { ...config.grid, ...overrides };
|
|
// `gap` is only emitted when someone chose one. Each preset declares its own
|
|
// photo spacing as part of its look (2px minimal, 40px monograph), and
|
|
// emitting the resolved fallback unconditionally overwrote that — the three
|
|
// presets that fought back with a hardcoded `column-gap` then ignored the
|
|
// setting entirely, which is #513. Inline beats the preset's declaration, so
|
|
// an explicit value still wins everywhere.
|
|
return {
|
|
'--grid-columns': g.columns,
|
|
...(overrides?.gap != null || config.gridGapExplicit ? { '--grid-gap': `${g.gap}px` } : {}),
|
|
'--grid-aspect-ratio': g.aspectRatio,
|
|
} as React.CSSProperties;
|
|
};
|
|
const resolveLayout = (overrides?: Partial<GridConfig>) =>
|
|
overrides?.layout ?? config.grid.layout;
|
|
|
|
// The album-cover grid on a subpage is sized by its own `coverGrid`, which
|
|
// touches nothing but the cover tiles (#523). An unset column count still
|
|
// falls back to the global one; `gap` deliberately does not follow the global
|
|
// setting — see buildCoverGridVars() for why.
|
|
const buildCoverGridStyle = (overrides?: Partial<GridConfig>): React.CSSProperties =>
|
|
buildCoverGridVars(overrides, config.grid.columns) as React.CSSProperties;
|
|
|
|
// Client proofing — `proofing.enabled` in settings.yaml is the default and a
|
|
// subpage's own `proofing:` flag overrides it in either direction. Albums
|
|
// reached without a subpage follow the global setting.
|
|
const proofingFor = (subpage?: { proofing?: boolean }) =>
|
|
resolveProofing(subpage, config.proofing.enabled);
|
|
// Lightbox zoom (#467): album, then the subpage it is shown on, then the
|
|
// site setting. The album id goes to toPhotoItems() when it resolves on.
|
|
const zoomAlbumFor = (albumId: string, subpage?: { zoom?: boolean }) =>
|
|
resolveZoom(config, albumId, subpage) ? albumId : undefined;
|
|
|
|
// EXPERIMENTAL: per-album grid override — merged over the subpage grid so
|
|
// the precedence is global < subpage < album.
|
|
const mergeAlbumGrid = (
|
|
albumId: string,
|
|
spGrid?: Partial<GridConfig>,
|
|
): Partial<GridConfig> | undefined => {
|
|
const albumGrid = config.albumGrids[albumId];
|
|
if (!albumGrid) return spGrid;
|
|
return { ...spGrid, ...albumGrid };
|
|
};
|
|
|
|
// One segment or two: nothing on this site lives deeper. A third segment
|
|
// used to fall through to the one-segment branch below, which reads only
|
|
// `path[0]`, so `/travel/iceland/anything` rendered the Travel subpage.
|
|
if (!path || path.length === 0 || path.length > 2) {
|
|
notFound();
|
|
}
|
|
|
|
// ── Two segments: subpage/album ──────────────────────────────
|
|
if (path.length === 2) {
|
|
const [subpageSlug, albumSlug] = path;
|
|
|
|
// Password gate for protected subpages
|
|
const gate = await gateIfProtected(subpageSlug);
|
|
if (gate) return gate;
|
|
|
|
const album = await immich.getAlbumBySlug(albumSlug, subpageSlug, forceFresh);
|
|
|
|
if (!album) {
|
|
if (await isAdminAuthenticated()) {
|
|
return (
|
|
<AdminDiagnosticBanner
|
|
slug={`${subpageSlug}/${albumSlug}`}
|
|
reason={`Album "${albumSlug}" could not be found or returned no assets from Immich.`}
|
|
/>
|
|
);
|
|
}
|
|
notFound();
|
|
}
|
|
|
|
// Look up subpage config for grid overrides and back link
|
|
const subpageData = await immich.getSubpageAlbums(subpageSlug, forceFresh);
|
|
const spGrid = subpageData?.subpage.grid;
|
|
const subpageName = subpageData
|
|
? subpageData.subpage.title || subpageData.subpage.name
|
|
: subpageSlug;
|
|
|
|
const images = toPhotoItems(
|
|
album.assets,
|
|
config.exif.onHover && config.exif.camera,
|
|
config.exif.caption,
|
|
config.albumDownloads[album.id] ? album.id : undefined,
|
|
zoomAlbumFor(album.id, subpageData?.subpage),
|
|
);
|
|
|
|
// Password gate for protected albums
|
|
const albumGate = await gateIfProtected(album.id, 'album', album.albumName);
|
|
if (albumGate) return albumGate;
|
|
|
|
const heroData = await getAlbumHeroData(album.id, config);
|
|
|
|
// Neighbours come from the subpage list the visitor just came through, in
|
|
// that list's own order, so "next" agrees with the grid they saw (#483).
|
|
const nav = subpageData ? albumNeighbours(subpageData.albums, album.id, `/${subpageSlug}`) : {};
|
|
|
|
return (
|
|
<AlbumDetailView
|
|
album={album}
|
|
images={images}
|
|
nav={nav}
|
|
structuredData={structuredDataFor(album, `/${subpageSlug}/${albumSlug}`, images.length)}
|
|
layout={resolveLayout(mergeAlbumGrid(album.id, spGrid))}
|
|
gridStyle={buildGridStyle(mergeAlbumGrid(album.id, spGrid))}
|
|
backLinkHref={`/${subpageSlug}`}
|
|
backLinkLabel={getServerDictionary().common.backTo(subpageName)}
|
|
watermark={config.watermark}
|
|
showExifPanel={hasExifPanelContent(config.exif)}
|
|
showGear={config.exif.camera}
|
|
proofing={proofingFor(subpageData?.subpage)}
|
|
allowMailto={config.proofing.allowMailto}
|
|
encodedMailto={encodedMailto}
|
|
downloadArchiveUrl={config.albumDownloads[album.id] ? archiveUrl(album.id) : undefined}
|
|
{...heroData}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// ── Single segment: subpage or standalone album ──────────────
|
|
const slug = path[0];
|
|
|
|
// Check if it's a subpage
|
|
if (immich.isSubpageSlug(slug)) {
|
|
// Password gate for protected subpages
|
|
const gate = await gateIfProtected(slug);
|
|
if (gate) return gate;
|
|
|
|
const result = await immich.getSubpageAlbums(slug, forceFresh);
|
|
if (!result || result.albums.length === 0) {
|
|
if (await isAdminAuthenticated()) {
|
|
const spConfig = config.subpages.find((sp) => sp.slug === slug);
|
|
return (
|
|
<AdminDiagnosticBanner
|
|
slug={slug}
|
|
subpageName={spConfig?.name}
|
|
configuredAlbumCount={spConfig?.albumIds.length}
|
|
/>
|
|
);
|
|
}
|
|
notFound();
|
|
}
|
|
|
|
const { albums } = result;
|
|
|
|
// ── Photo Essay / Storytelling Mode ───────────────────────
|
|
const isEssay =
|
|
result.subpage.grid?.layout === 'essay' ||
|
|
!!result.subpage.essayFile ||
|
|
!!result.subpage.essayText;
|
|
|
|
if (isEssay) {
|
|
const cookieStore = await cookies();
|
|
let essayParsed: ParsedEssay | null = null;
|
|
if (result.subpage.essayText) {
|
|
essayParsed = parseEssayMarkdown(result.subpage.essayText);
|
|
} else if (result.subpage.essayFile) {
|
|
// A borrowed journal entry keeps its own draft flag and password.
|
|
const file = resolveEssayFile(result.subpage.essayFile, {
|
|
isAdmin: await isAdminAuthenticated(),
|
|
getCookie: (name) => cookieStore.get(name)?.value,
|
|
});
|
|
if (file.status === 'locked') {
|
|
// The subpage's own title, which the nav already shows — not the
|
|
// borrowed entry's, which the journal index hides while it is locked.
|
|
return (
|
|
<PasswordGate
|
|
slug={result.subpage.essayFile}
|
|
title={result.subpage.title || result.subpage.name}
|
|
type="journal"
|
|
/>
|
|
);
|
|
}
|
|
if (file.status === 'open') essayParsed = file.parsed;
|
|
}
|
|
|
|
// 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 openAlbums = withoutLockedAlbums(albums, (name) => cookieStore.get(name)?.value);
|
|
const allAlbums = await Promise.all(
|
|
openAlbums.map((a) => immich.getAlbumBySlug(a.slug, slug, forceFresh)),
|
|
);
|
|
const allAssets = allAlbums.flatMap((a) => (a ? a.assets : []));
|
|
|
|
// Album blocks in an essay file: expand them the way the journal page
|
|
// does, from a raw fetch, and add those photos to the page's assets.
|
|
// Their ids are encoded here because this path hands EssayView tokens.
|
|
if (essayParsed?.blocks.some((b) => b.type === 'album')) {
|
|
const byAlbum = await loadAlbumBlocks(essayParsed.blocks, (albumId, error) => {
|
|
// eslint-disable-next-line no-console
|
|
console.warn(`[essay] ${slug}: album ${albumId} could not be loaded:`, error);
|
|
});
|
|
const expanded = expandAlbumBlocks(
|
|
essayParsed.blocks,
|
|
(id) => byAlbum.get(id),
|
|
config.albumManualOrders,
|
|
);
|
|
const albumIds = new Set(expanded.albumAssetIds);
|
|
const known = new Set(allAssets.map((a) => a.id));
|
|
for (const list of byAlbum.values()) {
|
|
for (const asset of list) {
|
|
if (!albumIds.has(asset.id) || known.has(asset.id)) continue;
|
|
known.add(asset.id);
|
|
allAssets.push(asset);
|
|
}
|
|
}
|
|
essayParsed = {
|
|
...essayParsed,
|
|
blocks: expanded.blocks.map((b) =>
|
|
mapBlockAssetIds(b, (id) => (albumIds.has(id) ? encodeAssetId(id) : id)),
|
|
),
|
|
};
|
|
}
|
|
|
|
const images = toPhotoItems(
|
|
allAssets,
|
|
config.exif.onHover && config.exif.camera,
|
|
config.exif.caption,
|
|
);
|
|
|
|
// Fallback structured essay if layout: 'essay' is set without custom markdown file
|
|
if (!essayParsed) {
|
|
essayParsed = {
|
|
frontmatter: {
|
|
title: result.subpage.title || result.subpage.name,
|
|
subtitle: result.subpage.subtitle,
|
|
},
|
|
blocks: openAlbums.flatMap((a) => [
|
|
{ type: 'heading' as const, level: 2, text: a.albumName },
|
|
...a.assets.map((asset) => ({
|
|
type: 'photo' as const,
|
|
assetId: encodeAssetId(asset.id),
|
|
caption: generatedEssayCaption(asset, config.exif.caption),
|
|
layout: 'contained' as const,
|
|
})),
|
|
]),
|
|
referencedAssetIds: [],
|
|
};
|
|
}
|
|
|
|
// Map blocks: pins from the subpage's own albums, each album's
|
|
// `location:` precision applied (an asset in two albums takes the
|
|
// stricter). Dropped entirely when the site has no map.
|
|
if (essayParsed.blocks.some((b) => b.type === 'map')) {
|
|
const precisionByAsset = new Map<string, LocationPrecision>();
|
|
for (const album of allAlbums) {
|
|
if (!album) continue;
|
|
const level = config.albumLocationPrecision[album.id] ?? 'exact';
|
|
for (const asset of album.assets) {
|
|
const prev = precisionByAsset.get(asset.id);
|
|
precisionByAsset.set(asset.id, prev ? strictestPrecision([prev, level]) : level);
|
|
}
|
|
}
|
|
const precisionOf = (id: string) => precisionByAsset.get(id) ?? 'exact';
|
|
const allIds = allAssets.map((a) => a.id);
|
|
essayParsed = {
|
|
...essayParsed,
|
|
blocks: essayParsed.blocks.flatMap((b): EssayBlock[] => {
|
|
if (b.type !== 'map') return [b];
|
|
if (!config.map) return [];
|
|
const pins = pinsForEntry(b.items, allAssets, allIds, precisionOf);
|
|
return [{ type: 'map', caption: b.caption, line: b.line, items: [], pins }];
|
|
}),
|
|
};
|
|
}
|
|
|
|
// A published story is not an album handover, so an essay only gets the
|
|
// proofing controls when its subpage asks for them explicitly — the
|
|
// global default does not reach in here.
|
|
const essayProofing = result.subpage.proofing === true;
|
|
|
|
return (
|
|
<EssayView
|
|
essay={essayParsed}
|
|
assets={images}
|
|
title={result.subpage.title || result.subpage.name}
|
|
subtitle={result.subpage.subtitle}
|
|
watermark={config.watermark}
|
|
proofing={essayProofing}
|
|
allowMailto={config.proofing.allowMailto}
|
|
encodedMailto={encodedMailto}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// ── Single album → full-bleed (skip album grid) ──────────
|
|
if (albums.length === 1) {
|
|
const album = await immich.getAlbumBySlug(albums[0].slug, slug, forceFresh);
|
|
if (!album) {
|
|
if (await isAdminAuthenticated()) {
|
|
return (
|
|
<AdminDiagnosticBanner
|
|
slug={slug}
|
|
subpageName={result.subpage.name}
|
|
configuredAlbumCount={result.subpage.albumIds.length}
|
|
reason={`Single album "${albums[0].albumName}" was not found or returned 0 assets.`}
|
|
/>
|
|
);
|
|
}
|
|
notFound();
|
|
}
|
|
|
|
const images = toPhotoItems(
|
|
album.assets,
|
|
config.exif.onHover && config.exif.camera,
|
|
config.exif.caption,
|
|
config.albumDownloads[album.id] ? album.id : undefined,
|
|
zoomAlbumFor(album.id, result.subpage),
|
|
);
|
|
|
|
// Password gate for protected albums
|
|
const albumGate = await gateIfProtected(album.id, 'album', album.albumName);
|
|
if (albumGate) return albumGate;
|
|
|
|
const heroData = await getAlbumHeroData(album.id, config);
|
|
|
|
return (
|
|
<AlbumDetailView
|
|
album={album}
|
|
images={images}
|
|
layout={resolveLayout(mergeAlbumGrid(album.id, result.subpage.grid))}
|
|
gridStyle={buildGridStyle(mergeAlbumGrid(album.id, result.subpage.grid))}
|
|
subtitle={result.subpage.subtitle}
|
|
backLinkHref="/"
|
|
backLinkLabel={getServerDictionary().common.backToGallery}
|
|
watermark={config.watermark}
|
|
showExifPanel={hasExifPanelContent(config.exif)}
|
|
showGear={config.exif.camera}
|
|
proofing={proofingFor(result.subpage)}
|
|
allowMailto={config.proofing.allowMailto}
|
|
encodedMailto={encodedMailto}
|
|
downloadArchiveUrl={config.albumDownloads[album.id] ? archiveUrl(album.id) : undefined}
|
|
{...heroData}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// ── Multiple albums → show album grid ─────────────────────
|
|
|
|
// Override albumThumbnailAssetId with the configured hero image (if any)
|
|
const albumsWithHero = albums.map((album) => {
|
|
const heroId = config.albumHeroImages[album.id];
|
|
// EXPERIMENTAL: coverPosition rides along so the card crop can honour
|
|
// the configured focal point.
|
|
const coverPosition = config.albumCoverPositions[album.id];
|
|
return {
|
|
...album,
|
|
...(heroId ? { albumThumbnailAssetId: heroId } : {}),
|
|
...(coverPosition ? { coverPosition } : {}),
|
|
};
|
|
});
|
|
|
|
// Batch-fetch the cover assets: ThumbHash for the placeholders, and
|
|
// whether the cover was edited in Immich, which its URL has to say (#831).
|
|
const coverAssets = await Promise.all(
|
|
albumsWithHero.map((album) =>
|
|
album.albumThumbnailAssetId ? immich.getAssetInfo(album.albumThumbnailAssetId) : null,
|
|
),
|
|
);
|
|
const coverPlaceholders = coverAssets.map((asset) => (asset ? assetPlaceholder(asset) : null));
|
|
const albumsWithCover = albumsWithHero.map((album, i) => {
|
|
const asset = coverAssets[i];
|
|
return asset
|
|
? {
|
|
...album,
|
|
cover: { id: asset.id, isEdited: asset.isEdited, updatedAt: asset.updatedAt },
|
|
}
|
|
: album;
|
|
});
|
|
|
|
// 1-based position among the enabled subpages — drives the header kicker.
|
|
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}
|
|
title={result.subpage.title || result.subpage.name}
|
|
subtitle={result.subpage.subtitle}
|
|
albums={albumsWithCover}
|
|
coverPlaceholders={coverPlaceholders}
|
|
sections={result.subpage.sections}
|
|
gridStyle={buildCoverGridStyle(result.subpage.coverGrid)}
|
|
{...(subpageIndex >= 0 ? { index: subpageIndex + 1 } : {})}
|
|
{...(nextSubpage
|
|
? {
|
|
nextSubpage: { slug: nextSubpage.slug, name: nextSubpage.title || nextSubpage.name },
|
|
}
|
|
: {})}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// A content page (#722). The admin refuses a page slug that collides with a
|
|
// subpage or an album, so the order here only matters for a hand-edited file.
|
|
if (path.length === 1) {
|
|
const page = await renderContentPage(slug);
|
|
if (page === 'not-found') notFound();
|
|
if (page) return page;
|
|
}
|
|
|
|
// Otherwise treat as a standalone album slug
|
|
const album = await immich.getAlbumBySlug(slug, undefined, forceFresh);
|
|
if (!album) {
|
|
if (await isAdminAuthenticated()) {
|
|
return (
|
|
<AdminDiagnosticBanner
|
|
slug={slug}
|
|
reason={`Standalone album slug "${slug}" could not be found in published Immich albums.`}
|
|
/>
|
|
);
|
|
}
|
|
notFound();
|
|
}
|
|
|
|
const images = toPhotoItems(
|
|
album.assets,
|
|
config.exif.onHover && config.exif.camera,
|
|
config.exif.caption,
|
|
config.albumDownloads[album.id] ? album.id : undefined,
|
|
zoomAlbumFor(album.id),
|
|
);
|
|
|
|
// Password gate for protected albums
|
|
const albumGate = await gateIfProtected(album.id, 'album', album.albumName);
|
|
if (albumGate) return albumGate;
|
|
|
|
const heroData = await getAlbumHeroData(album.id, config);
|
|
|
|
return (
|
|
<AlbumDetailView
|
|
album={album}
|
|
images={images}
|
|
layout={resolveLayout(mergeAlbumGrid(album.id))}
|
|
gridStyle={buildGridStyle(mergeAlbumGrid(album.id))}
|
|
backLinkHref="/"
|
|
backLinkLabel={getServerDictionary().common.backToGallery}
|
|
nav={albumNeighbours(await immich.getStandaloneAlbums(forceFresh), album.id)}
|
|
structuredData={structuredDataFor(album, `/${slug}`, images.length)}
|
|
watermark={config.watermark}
|
|
showExifPanel={hasExifPanelContent(config.exif)}
|
|
showGear={config.exif.camera}
|
|
proofing={proofingFor()}
|
|
allowMailto={config.proofing.allowMailto}
|
|
encodedMailto={encodedMailto}
|
|
downloadArchiveUrl={config.albumDownloads[album.id] ? archiveUrl(album.id) : undefined}
|
|
{...heroData}
|
|
/>
|
|
);
|
|
}
|