/** * A client proofing link: /proof/. * * Renders the session's album with the proofing controls in session mode — * hearts save to the server, and the client submits the selection when done. * The album does not have to be published; see immich.getProofingAlbum(). * * Kept out of search engines and the sitemap: the URL is the credential. */ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; import { immich } from '@/lib/immich'; import { getConfig, hasExifPanelContent } from '@/lib/config'; import { encodeAssetId } from '@/lib/tokens'; import { getServerDictionary } from '@/lib/i18n/server'; import { downloadsRemaining, findSessionByToken, isExpired } from '@/lib/proofSessions'; import { AlbumDetailView } from '../../[...path]/AlbumDetailView'; import { toPhotoItems } from '../../[...path]/photoItems'; export const dynamic = 'force-dynamic'; interface ProofPageProps { params: Promise<{ token: string }>; } export async function generateMetadata({ params }: ProofPageProps): Promise { const { token } = await params; const session = await findSessionByToken(token); const t = getServerDictionary(); return { // An expired link names nobody, not even in the tab title — but the tab // says the same as the page, rather than "not found" over "expired". title: !session ? t.error.notFoundTitle : isExpired(session) ? t.proofSession.expiredTitle : t.proofSession.greeting(session.clientName), robots: { index: false, follow: false }, // Never let the link travel as a Referer, not even to our own font host. referrer: 'no-referrer', }; } export default async function ProofPage({ params }: ProofPageProps) { const { token } = await params; const session = await findSessionByToken(token); if (!session) notFound(); const t = getServerDictionary(); if (isExpired(session)) { return (

{t.proofSession.expiredTitle}

{t.proofSession.expiredText}

); } const album = await immich.getProofingAlbum(session.albumId); if (!album) notFound(); const config = getConfig(); const images = toPhotoItems( album.assets, config.exif.onHover && config.exif.camera, config.exif.caption, ); // Only what is still in the album: a photo removed in Immich after it was // picked drops out of the selection instead of haunting the counter. const inAlbum = new Set(album.assets.map((a) => a.id)); const selected = session.selection.filter((id) => inAlbum.has(id)).map(encodeAssetId); const validUntil = session.expiresOn ? new Date(`${session.expiresOn}T12:00:00`).toLocaleDateString(t.dateLocale, { year: 'numeric', month: 'long', day: 'numeric', }) : null; const gridStyle = { '--grid-columns': config.grid.columns, ...(config.gridGapExplicit ? { '--grid-gap': `${config.grid.gap}px` } : {}), '--grid-aspect-ratio': config.grid.aspectRatio, } as React.CSSProperties; return (

{t.proofSession.greeting(session.clientName)}

{session.submittedAt ? t.proofSession.locked : t.proofSession.intro}

{validUntil && (

{t.proofSession.validUntil(validUntil)}

)} } /> ); }