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:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user