release: v0.12.0 (#520)

Forty-one commits since v0.11.0. See CHANGELOG.md for the full list and the upgrade notes.
This commit is contained in:
Ralksta
2026-08-23 23:18:14 +02:00
committed by GitHub
parent 2f546f440d
commit 10ec68f493
119 changed files with 6888 additions and 758 deletions

View File

@@ -6,6 +6,8 @@ import type { GridConfig } from '@/lib/config';
import type { LightboxWatermark } from '@/components/Lightbox';
import Image from 'next/image';
import React from 'react';
import { getServerDictionary } from '@/lib/i18n/server';
import { albumMetaDetail } from '@/lib/albumMeta';
interface AlbumDetailViewProps {
album: ImmichAlbum;
@@ -18,37 +20,16 @@ interface AlbumDetailViewProps {
heroImageUrl?: string;
heroBlurDataURL?: string;
watermark?: LightboxWatermark;
/** Whether the lightbox may offer its info panel at all. */
showExifPanel?: boolean;
/** Whether the header may name the camera and lens. Follows the `camera` group. */
showGear?: boolean;
/** Client proofing — favorite hearts, selection bar and send-off modal. */
proofing?: boolean;
/** Offer the "send by email" button in the proofing modal. */
allowMailto?: boolean;
}
/**
* 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,
@@ -60,10 +41,13 @@ export function AlbumDetailView({
heroImageUrl,
heroBlurDataURL,
watermark,
showExifPanel = true,
showGear = true,
proofing,
allowMailto,
}: AlbumDetailViewProps) {
const metaDetail = albumMetaDetail(album);
const metaDetail = albumMetaDetail(album, showGear);
const t = getServerDictionary();
return (
<>
@@ -95,9 +79,7 @@ export function AlbumDetailView({
{album.description && <p className="album-header__description">{album.description}</p>}
</div>
<p className="album-header__meta">
<span className="album-header__meta-count">
{images.length} {images.length === 1 ? 'photo' : 'photos'}
</span>
<span className="album-header__meta-count">{t.common.photos(images.length)}</span>
{metaDetail.date && <span className="album-header__meta-date"> · {metaDetail.date}</span>}
{metaDetail.gear && <span className="album-header__meta-gear">{metaDetail.gear}</span>}
</p>
@@ -107,6 +89,7 @@ export function AlbumDetailView({
layout={layout}
gridStyle={gridStyle}
watermark={watermark}
showExifPanel={showExifPanel}
proofing={proofing}
allowMailto={allowMailto}
/>

View File

@@ -9,6 +9,7 @@ import { FadeIn } from '@/components/FadeIn';
import type { ParsedEssay, EssayBlock } from '@/lib/essay';
import type { PhotoItem } from './PhotoGrid';
import './essay.css';
import { useDictionary } from '@/components/I18nProvider';
interface EssayViewProps {
essay: ParsedEssay;
@@ -34,6 +35,7 @@ function EssayViewContent({
watermark,
proofing: proofingEnabled = false,
}: EssayViewProps) {
const t = useDictionary();
const proofing = useProofing();
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
@@ -122,7 +124,7 @@ function EssayViewContent({
e.stopPropagation();
proofing.toggleFavorite(item.id);
}}
aria-label={isFav ? 'Remove favorite' : 'Add favorite'}
aria-label={isFav ? t.proofing.removeFavorite : t.proofing.addFavorite}
style={{
position: 'absolute',
top: '12px',
@@ -205,7 +207,7 @@ function EssayViewContent({
e.stopPropagation();
proofing.toggleFavorite(item.id);
}}
aria-label={isFav ? 'Remove favorite' : 'Add favorite'}
aria-label={isFav ? t.proofing.removeFavorite : t.proofing.addFavorite}
style={{
position: 'absolute',
top: '12px',
@@ -333,7 +335,9 @@ function EssayViewContent({
cursor: 'pointer',
}}
>
{proofing.isFilterActive ? 'Show All' : `❤️ ${proofing.favorites.size} Selected`}
{proofing.isFilterActive
? t.proofing.showAll
: t.proofing.selected(proofing.favorites.size)}
</button>
<button
type="button"
@@ -348,7 +352,7 @@ function EssayViewContent({
textDecoration: 'underline',
}}
>
Share & Export
{t.proofing.shareExport}
</button>
</div>
)}

View File

@@ -14,6 +14,7 @@ import { Lightbox, type LightboxWatermark } from '@/components/Lightbox';
import { FadeIn } from '@/components/FadeIn';
import { ProofingProvider, useProofing } from '@/components/ProofingContext';
import { ProofingModal } from '@/components/ProofingModal';
import { useDictionary } from '@/components/I18nProvider';
export interface PhotoItem {
id: string;
@@ -38,6 +39,11 @@ interface PhotoGridProps {
'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow' | 'essay' | 'justified';
gridStyle?: React.CSSProperties;
watermark?: LightboxWatermark;
/**
* Offer the lightbox info panel. Off when every EXIF group is switched off,
* so the `i` key does not open an empty panel (#506).
*/
showExifPanel?: boolean;
/**
* Client proofing — favorite hearts, the selection bar and the send-off
* modal. Off unless the caller opts in, so a grid rendered outside the
@@ -61,7 +67,14 @@ function buildPhotoHash(index: number): string {
return `#photo-${index + 1}`; // 1-indexed for user-friendliness
}
function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: PhotoGridProps) {
function PhotoGridInner({
assets,
layout = 'masonry',
gridStyle,
watermark,
showExifPanel = true,
}: PhotoGridProps) {
const t = useDictionary();
const proofing = useProofing();
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
@@ -190,8 +203,8 @@ function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: Ph
e.stopPropagation();
proofing.toggleFavorite(asset.id);
}}
aria-label={isFav ? 'Remove favorite' : 'Add favorite'}
title={isFav ? 'Remove favorite' : 'Add favorite'}
aria-label={isFav ? t.proofing.removeFavorite : t.proofing.addFavorite}
title={isFav ? t.proofing.removeFavorite : t.proofing.addFavorite}
style={{
position: 'absolute',
top: '8px',
@@ -240,7 +253,7 @@ function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: Ph
</FadeIn>
);
});
}, [displayedAssets, layout, openLightbox, proofing]);
}, [displayedAssets, layout, openLightbox, proofing, t]);
return (
<>
@@ -284,7 +297,9 @@ function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: Ph
cursor: 'pointer',
}}
>
{proofing.isFilterActive ? 'Show All' : `❤️ ${proofing.favorites.size} Selected`}
{proofing.isFilterActive
? t.proofing.showAll
: t.proofing.selected(proofing.favorites.size)}
</button>
<button
type="button"
@@ -299,7 +314,7 @@ function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: Ph
textDecoration: 'underline',
}}
>
Share & Export
{t.proofing.shareExport}
</button>
</div>
)}
@@ -314,6 +329,7 @@ function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: Ph
onNext={goNext}
onPrev={goPrev}
watermark={watermark}
showExifToggle={showExifPanel}
/>
)}
</>

View File

@@ -2,6 +2,7 @@ import Link from 'next/link';
import Image from 'next/image';
import { imageUrl } from '@/lib/urls';
import { SubpageSectionConfig } from '@/lib/config';
import { getServerDictionary } from '@/lib/i18n/server';
interface SubpageAlbum {
id: string;
@@ -27,22 +28,26 @@ interface SubpageGridViewProps {
sections?: SubpageSectionConfig[];
/** 1-based position among all subpages — feeds the "03 — Collection" kicker. */
index?: number;
/** `--subpage-columns` / `--subpage-gap` from the resolved grid config. */
gridStyle?: React.CSSProperties;
}
const pad2 = (n: number) => String(n).padStart(2, '0');
const photoCount = (n: number) => `${n} ${n === 1 ? 'photo' : 'photos'}`;
const photoCount = (n: number) => getServerDictionary().common.photos(n);
function AlbumGrid({
albums,
placeholderMap,
slug,
gridStyle,
}: {
albums: SubpageAlbum[];
placeholderMap: Map<string, Placeholder | null>;
slug: string;
gridStyle?: React.CSSProperties;
}) {
return (
<div className="subpage-grid">
<div className="subpage-grid" style={gridStyle}>
{albums.map((album, i) => {
const ph = placeholderMap.get(album.id) ?? null;
return (
@@ -51,7 +56,10 @@ function AlbumGrid({
href={`/${slug}/${album.slug}`}
className="subpage-grid__item"
style={ph?.dominantColor ? { backgroundColor: ph.dominantColor } : undefined}
aria-label={`${album.albumName}, ${photoCount(album.assetCount)}`}
aria-label={getServerDictionary().subpage.coverAria(
album.albumName,
photoCount(album.assetCount),
)}
>
<span className="subpage-grid__item-media">
{album.albumThumbnailAssetId ? (
@@ -105,11 +113,13 @@ export function SubpageGridView({
coverPlaceholders,
sections,
index,
gridStyle,
}: 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 t = getServerDictionary();
const hasSections = sections && sections.length > 0;
const totalPhotos = albums.reduce((sum, a) => sum + a.assetCount, 0);
@@ -120,21 +130,21 @@ export function SubpageGridView({
<div className="subpage-header__main">
{index !== undefined && (
<p className="subpage-header__kicker" aria-hidden="true">
{pad2(index)} — Collection
{t.subpage.collectionKicker(pad2(index))}
</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)}
{t.common.albums(albums.length)} · {photoCount(totalPhotos)}
</p>
</header>
)}
{/* Typographic Table of Contents */}
{hasSections && (
<nav className="subpage-toc" aria-label="Sections">
<nav className="subpage-toc" aria-label={t.subpage.sectionsNav}>
{sections.map((sec, i) => (
<span key={sec.slug} className="subpage-toc__entry">
<a href={`#${sec.slug}`} className="subpage-toc__link">
@@ -160,13 +170,23 @@ export function SubpageGridView({
{sec.description && <p className="subpage-section__desc">{sec.description}</p>}
<div className="subpage-section__rule" aria-hidden="true" />
</header>
<AlbumGrid albums={sectionAlbums} placeholderMap={placeholderMap} slug={slug} />
<AlbumGrid
albums={sectionAlbums}
placeholderMap={placeholderMap}
slug={slug}
gridStyle={gridStyle}
/>
</section>
);
})
) : (
/* Flat layout (no sections) */
<AlbumGrid albums={albums} placeholderMap={placeholderMap} slug={slug} />
<AlbumGrid
albums={albums}
placeholderMap={placeholderMap}
slug={slug}
gridStyle={gridStyle}
/>
)}
</div>
);

View File

@@ -5,12 +5,14 @@
*/
import styles from '../loading.module.css';
import { getServerDictionary } from '@/lib/i18n/server';
export default function Loading() {
const t = getServerDictionary();
return (
<>
<div className={styles.heading} aria-hidden="true" />
<div className={styles.grid} role="status" aria-label="Loading photos">
<div className={styles.grid} role="status" aria-label={t.common.loadingPhotos}>
{Array.from({ length: 9 }).map((_, i) => (
<div key={i} className={styles.tile} />
))}

View File

@@ -24,7 +24,13 @@ import {
assetAspectRatio,
} from '@/lib/urls';
import { encodeAssetId } from '@/lib/tokens';
import { getConfig, resolveProofing, type GridConfig } from '@/lib/config';
import {
buildCoverGridVars,
getConfig,
hasExifPanelContent,
resolveProofing,
type GridConfig,
} from '@/lib/config';
import { isProtected, isAuthenticated } from '@/lib/auth';
import { isAdminAuthenticated } from '@/lib/admin/auth';
import PasswordGate from '@/components/PasswordGate';
@@ -34,6 +40,7 @@ import { SubpageGridView } from './SubpageGridView';
import { EssayView } from './EssayView';
import { parseEssayMarkdown } from '@/lib/essay';
import { loadEssayFromFile } from '@/lib/admin/journal-service';
import { getServerDictionary } from '@/lib/i18n/server';
// Render at request time — requires live Immich connection
export const dynamic = 'force-dynamic';
@@ -104,6 +111,10 @@ export async function generateMetadata({ params }: PathPageProps): Promise<Metad
}
/** Map Immich assets to PhotoItem props for the grid/lightbox. */
/**
* `showExif` covers the hover overlay only, and that overlay carries camera,
* lens and focal length — so it follows the `camera` group, not the panel.
*/
function toPhotoItems(assets: ImmichAsset[], showExif: boolean): PhotoItem[] {
return assets
.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO')
@@ -173,15 +184,28 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
// 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,
'--grid-gap': `${g.gap}px`,
...(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 takes its column count from the same
// config as the photo grids, so "3 columns" in the admin means three columns
// everywhere. `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.
@@ -230,7 +254,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
const spGrid = subpageData?.subpage.grid;
const subpageName = subpageData?.subpage.name ?? subpageSlug;
const images = toPhotoItems(album.assets, config.exifOnHover);
const images = toPhotoItems(album.assets, config.exif.onHover && config.exif.camera);
// Password gate for protected albums
const albumGate = await gateIfProtected(album.id, 'album', album.albumName);
@@ -247,6 +271,8 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
backLinkHref={`/${subpageSlug}`}
backLinkLabel={`Back to ${subpageName}`}
watermark={config.watermark}
showExifPanel={hasExifPanelContent(config.exif)}
showGear={config.exif.camera}
proofing={proofingFor(subpageData?.subpage)}
allowMailto={config.proofing.allowMailto}
{...heroData}
@@ -298,7 +324,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
albums.map((a) => immich.getAlbumBySlug(a.slug, slug, forceFresh)),
);
const allAssets = allAlbums.flatMap((a) => (a ? a.assets : []));
const images = toPhotoItems(allAssets, config.exifOnHover);
const images = toPhotoItems(allAssets, config.exif.onHover && config.exif.camera);
// Fallback structured essay if layout: 'essay' is set without custom markdown file
if (!essayParsed) {
@@ -355,7 +381,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
notFound();
}
const images = toPhotoItems(album.assets, config.exifOnHover);
const images = toPhotoItems(album.assets, config.exif.onHover && config.exif.camera);
// Password gate for protected albums
const albumGate = await gateIfProtected(album.id, 'album', album.albumName);
@@ -371,8 +397,10 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
gridStyle={buildGridStyle(mergeAlbumGrid(album.id, result.subpage.grid))}
subtitle={result.subpage.subtitle}
backLinkHref="/"
backLinkLabel="Back to Gallery"
backLinkLabel={getServerDictionary().common.backToGallery}
watermark={config.watermark}
showExifPanel={hasExifPanelContent(config.exif)}
showGear={config.exif.camera}
proofing={proofingFor(result.subpage)}
allowMailto={config.proofing.allowMailto}
{...heroData}
@@ -416,6 +444,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
albums={albumsWithHero}
coverPlaceholders={coverPlaceholders}
sections={result.subpage.sections}
gridStyle={buildCoverGridStyle(result.subpage.grid)}
{...(subpageIndex >= 0 ? { index: subpageIndex + 1 } : {})}
/>
);
@@ -435,7 +464,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
notFound();
}
const images = toPhotoItems(album.assets, config.exifOnHover);
const images = toPhotoItems(album.assets, config.exif.onHover && config.exif.camera);
// Password gate for protected albums
const albumGate = await gateIfProtected(album.id, 'album', album.albumName);
@@ -450,8 +479,10 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
layout={resolveLayout(mergeAlbumGrid(album.id))}
gridStyle={buildGridStyle(mergeAlbumGrid(album.id))}
backLinkHref="/"
backLinkLabel="Back to Gallery"
backLinkLabel={getServerDictionary().common.backToGallery}
watermark={config.watermark}
showExifPanel={hasExifPanelContent(config.exif)}
showGear={config.exif.camera}
proofing={proofingFor()}
allowMailto={config.proofing.allowMailto}
{...heroData}