diff --git a/app/[...path]/page.tsx b/app/[...path]/page.tsx index e79491e..4116472 100644 --- a/app/[...path]/page.tsx +++ b/app/[...path]/page.tsx @@ -106,6 +106,21 @@ function toPhotoItems(assets: ImmichAsset[], showExif: boolean): PhotoItem[] { }); } +/** + * Returns a PasswordGate element if the slug is protected and not yet + * authenticated, otherwise returns null. Centralises the duplicated + * cookie-check boilerplate. + */ +async function gateIfProtected(slug: string): Promise { + if (!isProtected(slug)) return null; + const cookieStore = await cookies(); + const getCookie = (name: string) => cookieStore.get(name)?.value; + if (isAuthenticated(slug, getCookie)) return null; + const subpageData = await immich.getSubpageAlbums(slug); + const title = subpageData?.subpage.name ?? slug; + return ; +} + export default async function PathPage({ params }: PathPageProps) { const { path } = await params; const config = getConfig(); @@ -131,15 +146,8 @@ export default async function PathPage({ params }: PathPageProps) { const [subpageSlug, albumSlug] = path; // Password gate for protected subpages - if (isProtected(subpageSlug)) { - const cookieStore = await cookies(); - const getCookie = (name: string) => cookieStore.get(name)?.value; - if (!isAuthenticated(subpageSlug, getCookie)) { - const subpageData = await immich.getSubpageAlbums(subpageSlug); - const subpageName = subpageData?.subpage.name ?? subpageSlug; - return ; - } - } + const gate = await gateIfProtected(subpageSlug); + if (gate) return gate; const album = await immich.getAlbumBySlug(albumSlug, subpageSlug); @@ -172,15 +180,8 @@ export default async function PathPage({ params }: PathPageProps) { // Check if it's a subpage if (immich.isSubpageSlug(slug)) { // Password gate for protected subpages - if (isProtected(slug)) { - const cookieStore = await cookies(); - const getCookie = (name: string) => cookieStore.get(name)?.value; - if (!isAuthenticated(slug, getCookie)) { - const subpageData = await immich.getSubpageAlbums(slug); - const subpageName = subpageData?.subpage.name ?? slug; - return ; - } - } + const gate = await gateIfProtected(slug); + if (gate) return gate; const result = await immich.getSubpageAlbums(slug); if (!result || result.albums.length === 0) { diff --git a/app/page.tsx b/app/page.tsx index e8f84be..0c1ded3 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -140,6 +140,27 @@ export default async function HomePage() { ); } + /** Title + optional subtitle + nav — shared by mosaic, minimal, fullbleed, split. */ + function HeroTextContent() { + return ( + <> + +

{config.siteTitle}

+
+ {config.siteSubtitle && ( + +

{config.siteSubtitle}

+
+ )} + + + + + ); + } + // ── Mosaic: multi-image grid with frosted title overlay ──────── if (heroStyle === 'mosaic') { return ( @@ -161,19 +182,7 @@ export default async function HomePage() { ))}
- -

{config.siteTitle}

-
- {config.siteSubtitle && ( - -

{config.siteSubtitle}

-
- )} - - - +
); @@ -184,19 +193,7 @@ export default async function HomePage() { return (
- -

{config.siteTitle}

-
- {config.siteSubtitle && ( - -

{config.siteSubtitle}

-
- )} - - - +
); @@ -208,19 +205,7 @@ export default async function HomePage() {
- -

{config.siteTitle}

-
- {config.siteSubtitle && ( - -

{config.siteSubtitle}

-
- )} - - - +
); @@ -232,20 +217,7 @@ export default async function HomePage() { {/* ── Left Panel ──────────────────────────────── */}
- -

{config.siteTitle}

-
- {config.siteSubtitle && ( - -

{config.siteSubtitle}

-
- )} - - - - +
diff --git a/lib/__tests__/config.test.ts b/lib/__tests__/config.test.ts index e90597d..35663fd 100644 --- a/lib/__tests__/config.test.ts +++ b/lib/__tests__/config.test.ts @@ -12,7 +12,7 @@ vi.mock('@/lib/env', () => ({ }, })); -import { slugify } from '@/lib/config'; +import { slugify, buildSubpageGrid } from '@/lib/config'; describe('slugify', () => { it('converts a simple name to lowercase with hyphens', () => { @@ -51,3 +51,50 @@ describe('slugify', () => { expect(slugify('Album 2024')).toBe('album-2024'); }); }); + +describe('buildSubpageGrid', () => { + it('returns empty object when called without arguments', () => { + expect(buildSubpageGrid()).toEqual({}); + }); + + it('returns empty object when called with undefined', () => { + expect(buildSubpageGrid(undefined)).toEqual({}); + }); + + it('maps columns and gap correctly', () => { + expect(buildSubpageGrid({ columns: 4, gap: 8 })).toEqual({ + grid: { columns: 4, gap: 8 }, + }); + }); + + it('maps aspectRatio correctly', () => { + expect(buildSubpageGrid({ aspectRatio: '16/9' })).toEqual({ + grid: { aspectRatio: '16/9' }, + }); + }); + + it('preserves a valid layout value', () => { + expect(buildSubpageGrid({ layout: 'uniform' })).toEqual({ + grid: { layout: 'uniform' }, + }); + }); + + it('falls back to masonry for an unknown layout', () => { + expect(buildSubpageGrid({ layout: 'unknown-layout' })).toEqual({ + grid: { layout: 'masonry' }, + }); + }); + + it('omits fields that are not supplied', () => { + const result = buildSubpageGrid({ columns: 2 }); + expect(result).toEqual({ grid: { columns: 2 } }); + expect((result as { grid: object }).grid).not.toHaveProperty('gap'); + expect((result as { grid: object }).grid).not.toHaveProperty('layout'); + }); + + it('handles all fields together', () => { + expect( + buildSubpageGrid({ columns: 3, gap: 16, aspectRatio: '1', layout: 'masonry' }), + ).toEqual({ grid: { columns: 3, gap: 16, aspectRatio: '1', layout: 'masonry' } }); + }); +}); diff --git a/lib/config/index.ts b/lib/config/index.ts index 0606645..8242d59 100644 --- a/lib/config/index.ts +++ b/lib/config/index.ts @@ -15,6 +15,28 @@ export * from './schema'; export * from './theme'; let _config: AppConfig | null = null; + +/** Converts raw YAML grid overrides into a typed partial GridConfig. */ +export function buildSubpageGrid( + raw?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string }, +): { grid: Partial } | Record { + if (!raw) return {}; + return { + grid: { + ...(raw.columns != null ? { columns: raw.columns } : {}), + ...(raw.gap != null ? { gap: raw.gap } : {}), + ...(raw.aspectRatio != null ? { aspectRatio: raw.aspectRatio } : {}), + ...(raw.layout != null + ? { + layout: (VALID_LAYOUTS.includes(raw.layout) + ? raw.layout + : 'masonry') as GridConfig['layout'], + } + : {}), + }, + }; +} + export function getConfig(): AppConfig { const isDev = process.env.NODE_ENV !== 'production'; if (_config && !isDev) return _config; @@ -135,22 +157,7 @@ export function getConfig(): AppConfig { albumIds, sections, password: sp.password, - ...(sp.grid - ? { - grid: { - ...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}), - ...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}), - ...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}), - ...(sp.grid.layout != null - ? { - layout: (VALID_LAYOUTS.includes(sp.grid.layout) - ? sp.grid.layout - : 'masonry') as GridConfig['layout'], - } - : {}), - }, - } - : {}), + ...buildSubpageGrid(sp.grid), }; }); } else if (gallery.subpages) { @@ -178,22 +185,7 @@ export function getConfig(): AppConfig { subtitle: sp.subtitle, albumIds: albumEntries.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)), password: sp.password, - ...(sp.grid - ? { - grid: { - ...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}), - ...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}), - ...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}), - ...(sp.grid.layout != null - ? { - layout: (VALID_LAYOUTS.includes(sp.grid.layout) - ? sp.grid.layout - : 'masonry') as GridConfig['layout'], - } - : {}), - }, - } - : {}), + ...buildSubpageGrid(sp.grid), }; }); } diff --git a/lib/config/theme.ts b/lib/config/theme.ts index 231ee92..8d18fc1 100644 --- a/lib/config/theme.ts +++ b/lib/config/theme.ts @@ -61,16 +61,6 @@ export const THEME_PRESETS: Record = { headerDot: false, heroStyle: 'typographic', }, - botanica: { - preset: 'botanica', - accent: '#4a7c59', - fonts: { heading: 'Cormorant Garamond', body: 'Nunito Sans', caption: 'Inconsolata' }, - radius: 8, - photoFrame: 'none', - grain: false, - headerDot: true, - heroStyle: 'split', - }, }; export const VALID_PHOTO_FRAMES = ['none', 'passepartout', 'shadow'];