chore(release): v0.10.0 (#400)
Photo Essay mode, client proofing, studio-modern preset, admin panel overhaul, watermark, cookieless analytics, subpage toggle, SEO title templates. Analytics API now admin-only, Trivy scan on publish. Album order mirrors the Immich timeline; map theme, admin status panel, lightbox counter and Docker health check fixed. See CHANGELOG.md for the full list and the health-check upgrade note.
This commit is contained in:
@@ -20,6 +20,31 @@ interface AlbumDetailViewProps {
|
||||
watermark?: LightboxWatermark;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra detail for the album header — shooting date and camera/lens, read off
|
||||
* the first asset that carries EXIF. Both are rendered in their own spans and
|
||||
* hidden by default; only presets that ask for them show them, so the header
|
||||
* keeps reading "86 photos" everywhere else.
|
||||
*/
|
||||
function albumMetaDetail(album: ImmichAlbum): { date?: string; gear?: string } {
|
||||
const first = album.assets.find((a) => a.exifInfo);
|
||||
const exif = first?.exifInfo;
|
||||
|
||||
const taken = exif?.dateTimeOriginal || first?.fileCreatedAt;
|
||||
const gear = [exif?.model, exif?.lensModel].filter(Boolean).join(' · ');
|
||||
|
||||
return {
|
||||
...(taken ? { date: formatMonthYear(taken) } : {}),
|
||||
...(gear ? { gear } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function formatMonthYear(iso: string): string | undefined {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return undefined;
|
||||
return `${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
export function AlbumDetailView({
|
||||
album,
|
||||
images,
|
||||
@@ -32,6 +57,8 @@ export function AlbumDetailView({
|
||||
heroBlurDataURL,
|
||||
watermark,
|
||||
}: AlbumDetailViewProps) {
|
||||
const metaDetail = albumMetaDetail(album);
|
||||
|
||||
return (
|
||||
<>
|
||||
{heroImageUrl && (
|
||||
@@ -49,16 +76,26 @@ export function AlbumDetailView({
|
||||
</div>
|
||||
)}
|
||||
<div className={`album-header${heroImageUrl ? ' album-header--has-hero' : ''}`}>
|
||||
<BackLink href={backLinkHref} label={backLinkLabel} />
|
||||
<h1 className="album-header__title">{album.albumName}</h1>
|
||||
{subtitle && (
|
||||
<p className="subpage-subtitle" style={{ textAlign: 'left', marginLeft: 0 }}>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
{album.description && <p className="album-header__description">{album.description}</p>}
|
||||
<div className="album-header__main">
|
||||
<BackLink href={backLinkHref} label={backLinkLabel} />
|
||||
<h1 className="album-header__title">{album.albumName}</h1>
|
||||
{subtitle && (
|
||||
<p className="subpage-subtitle" style={{ textAlign: 'left', marginLeft: 0 }}>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
{album.description && <p className="album-header__description">{album.description}</p>}
|
||||
</div>
|
||||
<p className="album-header__meta">
|
||||
{images.length} {images.length === 1 ? 'photo' : 'photos'}
|
||||
<span className="album-header__meta-count">
|
||||
{images.length} {images.length === 1 ? 'photo' : 'photos'}
|
||||
</span>
|
||||
{metaDetail.date && (
|
||||
<span className="album-header__meta-date"> · {metaDetail.date}</span>
|
||||
)}
|
||||
{metaDetail.gear && (
|
||||
<span className="album-header__meta-gear">{metaDetail.gear}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<PhotoGrid assets={images} layout={layout} gridStyle={gridStyle} watermark={watermark} />
|
||||
|
||||
@@ -23,8 +23,13 @@ interface SubpageGridViewProps {
|
||||
albums: SubpageAlbum[];
|
||||
coverPlaceholders: (Placeholder | null)[];
|
||||
sections?: SubpageSectionConfig[];
|
||||
/** 1-based position among all subpages — feeds the "03 — Collection" kicker. */
|
||||
index?: number;
|
||||
}
|
||||
|
||||
const pad2 = (n: number) => String(n).padStart(2, '0');
|
||||
const photoCount = (n: number) => `${n} ${n === 1 ? 'photo' : 'photos'}`;
|
||||
|
||||
function AlbumGrid({
|
||||
albums,
|
||||
placeholderMap,
|
||||
@@ -36,7 +41,7 @@ function AlbumGrid({
|
||||
}) {
|
||||
return (
|
||||
<div className="subpage-grid">
|
||||
{albums.map((album) => {
|
||||
{albums.map((album, i) => {
|
||||
const ph = placeholderMap.get(album.id) ?? null;
|
||||
return (
|
||||
<Link
|
||||
@@ -44,33 +49,42 @@ function AlbumGrid({
|
||||
href={`/${slug}/${album.slug}`}
|
||||
className="subpage-grid__item"
|
||||
style={ph?.dominantColor ? { backgroundColor: ph.dominantColor } : undefined}
|
||||
aria-label={`${album.albumName}, ${album.assetCount} ${album.assetCount === 1 ? 'photo' : 'photos'}`}
|
||||
aria-label={`${album.albumName}, ${photoCount(album.assetCount)}`}
|
||||
>
|
||||
{album.albumThumbnailAssetId ? (
|
||||
<Image
|
||||
src={imageUrl(album.albumThumbnailAssetId, 'preview')}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 600px) 100vw, (max-width: 1000px) 50vw, 33vw"
|
||||
loading="lazy"
|
||||
{...(ph ? { placeholder: 'blur' as const, blurDataURL: ph.blurDataURL } : {})}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="skeleton"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span className="subpage-grid__item-badge" aria-hidden="true">
|
||||
{album.assetCount} {album.assetCount === 1 ? 'photo' : 'photos'}
|
||||
</span>
|
||||
<div className="subpage-grid__item-overlay" aria-hidden="true">
|
||||
<span className="subpage-grid__item-title">{album.albumName}</span>
|
||||
<span className="subpage-grid__item-count">
|
||||
{album.assetCount} {album.assetCount === 1 ? 'photo' : 'photos'}
|
||||
<span className="subpage-grid__item-media">
|
||||
{album.albumThumbnailAssetId ? (
|
||||
<Image
|
||||
src={imageUrl(album.albumThumbnailAssetId, 'preview')}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 600px) 100vw, (max-width: 1000px) 50vw, 33vw"
|
||||
loading="lazy"
|
||||
{...(ph ? { placeholder: 'blur' as const, blurDataURL: ph.blurDataURL } : {})}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="skeleton"
|
||||
style={{ display: 'block', width: '100%', height: '100%' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span className="subpage-grid__item-badge" aria-hidden="true">
|
||||
{photoCount(album.assetCount)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="subpage-grid__item-overlay" aria-hidden="true">
|
||||
<span className="subpage-grid__item-title">{album.albumName}</span>
|
||||
<span className="subpage-grid__item-count">{photoCount(album.assetCount)}</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Always-visible caption bar — shown by presets that use it. */}
|
||||
<span className="subpage-grid__item-caption" aria-hidden="true">
|
||||
<span className="subpage-grid__item-index">{pad2(i + 1)}</span>
|
||||
<span className="subpage-grid__item-caption-title">{album.albumName}</span>
|
||||
<span className="subpage-grid__item-caption-count">
|
||||
{photoCount(album.assetCount)}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
@@ -85,19 +99,31 @@ export function SubpageGridView({
|
||||
albums,
|
||||
coverPlaceholders,
|
||||
sections,
|
||||
index,
|
||||
}: SubpageGridViewProps) {
|
||||
// Build lookup maps once
|
||||
const albumMap = new Map(albums.map((a) => [a.id, a]));
|
||||
const placeholderMap = new Map(albums.map((a, i) => [a.id, coverPlaceholders[i] ?? null]));
|
||||
|
||||
const hasSections = sections && sections.length > 0;
|
||||
const totalPhotos = albums.reduce((sum, a) => sum + a.assetCount, 0);
|
||||
|
||||
return (
|
||||
<div className="subpage-container">
|
||||
{(title || subtitle) && (
|
||||
<header className="subpage-header">
|
||||
{title && <h1 className="subpage-title">{title}</h1>}
|
||||
{subtitle && <p className="subpage-subtitle">{subtitle}</p>}
|
||||
<div className="subpage-header__main">
|
||||
{index !== undefined && (
|
||||
<p className="subpage-header__kicker" aria-hidden="true">
|
||||
{pad2(index)} — Collection
|
||||
</p>
|
||||
)}
|
||||
{title && <h1 className="subpage-title">{title}</h1>}
|
||||
{subtitle && <p className="subpage-subtitle">{subtitle}</p>}
|
||||
</div>
|
||||
<p className="subpage-header__meta" aria-hidden="true">
|
||||
{albums.length} {albums.length === 1 ? 'album' : 'albums'} · {photoCount(totalPhotos)}
|
||||
</p>
|
||||
</header>
|
||||
)}
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@ import { cookies } from 'next/headers';
|
||||
import type { Metadata } from 'next';
|
||||
import { immich, type ImmichAsset } from '@/lib/immich';
|
||||
import { notFound } from 'next/navigation';
|
||||
// Only the type: rendering moved to AlbumDetailView/SubpageGridView, which
|
||||
// import the component themselves.
|
||||
import type { PhotoItem } from './PhotoGrid';
|
||||
import {
|
||||
imageUrl,
|
||||
@@ -28,7 +26,9 @@ import {
|
||||
import { encodeAssetId } from '@/lib/tokens';
|
||||
import { getConfig, type GridConfig } from '@/lib/config';
|
||||
import { isProtected, isAuthenticated } from '@/lib/auth';
|
||||
import { isAdminAuthenticated } from '@/lib/admin/auth';
|
||||
import PasswordGate from '@/components/PasswordGate';
|
||||
import { AdminDiagnosticBanner } from '@/components/AdminDiagnosticBanner';
|
||||
import { AlbumDetailView } from './AlbumDetailView';
|
||||
import { SubpageGridView } from './SubpageGridView';
|
||||
import { EssayView } from './EssayView';
|
||||
@@ -39,6 +39,7 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
interface PathPageProps {
|
||||
params: Promise<{ path: string[] }>;
|
||||
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PathPageProps): Promise<Metadata> {
|
||||
@@ -110,8 +111,6 @@ function toPhotoItems(assets: ImmichAsset[], showExif: boolean): PhotoItem[] {
|
||||
const exif = showExif && a.type === 'IMAGE' ? assetExifSummary(a) : undefined;
|
||||
const isVideo = a.type === 'VIDEO';
|
||||
return {
|
||||
// Opaque token, never the raw Immich UUID — this object is serialized
|
||||
// into the RSC payload and shipped to the browser. Used only as a React key.
|
||||
id: encodeAssetId(a.id),
|
||||
type: isVideo ? 'video' : 'image',
|
||||
thumbUrl: imageUrl(a.id, 'preview'),
|
||||
@@ -163,8 +162,11 @@ async function getAlbumHeroData(
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PathPage({ params }: PathPageProps) {
|
||||
export default async function PathPage({ params, searchParams }: PathPageProps) {
|
||||
const { path } = await params;
|
||||
const sParams = (await searchParams) || {};
|
||||
const forceFresh = sParams.fresh === '1' || sParams.preview === 'true';
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
// Build grid CSS custom properties, optionally merging subpage overrides
|
||||
@@ -191,14 +193,22 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
const gate = await gateIfProtected(subpageSlug);
|
||||
if (gate) return gate;
|
||||
|
||||
const album = await immich.getAlbumBySlug(albumSlug, subpageSlug);
|
||||
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);
|
||||
const subpageData = await immich.getSubpageAlbums(subpageSlug, forceFresh);
|
||||
const spGrid = subpageData?.subpage.grid;
|
||||
const subpageName = subpageData?.subpage.name ?? subpageSlug;
|
||||
|
||||
@@ -233,8 +243,18 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
const gate = await gateIfProtected(slug);
|
||||
if (gate) return gate;
|
||||
|
||||
const result = await immich.getSubpageAlbums(slug);
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -255,7 +275,7 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
|
||||
// Fetch assets from all subpage albums
|
||||
const allAlbums = await Promise.all(
|
||||
albums.map((a) => immich.getAlbumBySlug(a.slug, slug))
|
||||
albums.map((a) => immich.getAlbumBySlug(a.slug, slug, forceFresh))
|
||||
);
|
||||
const allAssets = allAlbums.flatMap((a) => (a ? a.assets : []));
|
||||
const images = toPhotoItems(allAssets, config.exifOnHover);
|
||||
@@ -293,8 +313,20 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
|
||||
// ── Single album → full-bleed (skip album grid) ──────────
|
||||
if (albums.length === 1) {
|
||||
const album = await immich.getAlbumBySlug(albums[0].slug, slug);
|
||||
if (!album) notFound();
|
||||
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.exifOnHover);
|
||||
|
||||
@@ -336,6 +368,10 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
}),
|
||||
);
|
||||
|
||||
// 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);
|
||||
|
||||
return (
|
||||
<SubpageGridView
|
||||
slug={slug}
|
||||
@@ -344,13 +380,22 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
albums={albumsWithHero}
|
||||
coverPlaceholders={coverPlaceholders}
|
||||
sections={result.subpage.sections}
|
||||
{...(subpageIndex >= 0 ? { index: subpageIndex + 1 } : {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise treat as a standalone album slug
|
||||
const album = await immich.getAlbumBySlug(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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user