/** * Homepage — renders different hero layouts based on theme config. * * Styles: split, fullbleed, minimal, stacked, typographic, mosaic. */ import Link from 'next/link'; import Image from 'next/image'; import { immich, type ImmichAsset } from '@/lib/immich'; import { getConfig } from '@/lib/config'; import { imageUrl, assetPlaceholder } from '@/lib/urls'; import { HeroCarousel } from '@/components/HeroCarousel'; import { FadeIn } from '@/components/FadeIn'; import { getServerDictionary } from '@/lib/i18n/server'; import type { Dictionary } from '@/lib/i18n'; // Render at request time — requires live Immich connection export const dynamic = 'force-dynamic'; type HeroSubpage = { slug: string; name: string; albumCount: number }; type HeroAlbum = { id: string; slug: string; albumName: string; assetCount: number }; const pad2 = (n: number) => String(n).padStart(2, '0'); /** * Flatten subpages + standalone albums into one indexed nav list. * Every entry carries an index and a count; presets decide what to show. */ function heroNavEntries(subpages: HeroSubpage[], albums: HeroAlbum[], t: Dictionary) { return [ ...subpages.map((sp) => ({ key: `sp-${sp.slug}`, href: `/${sp.slug}`, label: sp.name, count: t.common.albums(sp.albumCount), })), ...albums.map((a) => ({ key: `al-${a.id}`, href: `/${a.slug}`, label: a.albumName, count: t.common.photos(a.assetCount), })), ]; } /** * Camera line for the hero chip: "Q3 · 28MM · ƒ/5.6 · 1/250 · ISO 200". * Returns undefined when the asset carries no usable EXIF. */ function heroExifLine(asset: ImmichAsset): string | undefined { const e = asset.exifInfo; if (!e) return undefined; const parts = [ e.city || e.model || undefined, e.focalLength ? `${Math.round(e.focalLength)}mm` : undefined, e.fNumber ? `ƒ/${e.fNumber}` : undefined, e.exposureTime ? `${e.exposureTime}s` : undefined, e.iso ? `ISO ${e.iso}` : undefined, ].filter(Boolean); return parts.length > 0 ? parts.join(' · ') : undefined; } /** Shared nav links for hero sections. */ function HeroNavLinks({ subpages, albums }: { subpages: HeroSubpage[]; albums: HeroAlbum[] }) { const t = getServerDictionary(); return ( <> {heroNavEntries(subpages, albums, t).map((entry, i) => ( {entry.label} ))} ); } /** Title + optional subtitle + nav — shared by mosaic, minimal, fullbleed, split. */ function HeroTextContent({ title, subtitle, subpages, albums, }: { title: string; subtitle?: string; subpages: HeroSubpage[]; albums: HeroAlbum[]; }) { return ( <>

{title}

{subtitle && (

{subtitle}

)} ); } export default async function HomePage() { const config = getConfig(); const t = getServerDictionary(); if (config.needsSetup) { return null; } const [subpages, albums] = await Promise.all([ immich.getSubpages(), immich.getStandaloneAlbums(), ]); // Fetch ThumbHash + camera line for all hero images const heroData = await Promise.all( config.heroImages.map(async (id) => { const asset = await immich.getAssetInfo(id); const ph = asset ? assetPlaceholder(asset) : null; const exif = asset ? heroExifLine(asset) : undefined; return { src: imageUrl(id, 'preview'), ...(ph ? { blurDataURL: ph.blurDataURL } : {}), ...(exif ? { exif } : {}), }; }), ); const heroStyle = config.theme.heroStyle; // ── Cover (EXPERIMENTAL): fullscreen splash + single "Enter" link ─ // Welcome-page splash: one fullbleed image, the site // title, and one link into the portfolio (the first nav entry). if (heroStyle === 'cover') { const entries = heroNavEntries(subpages, albums, t); const enterHref = entries[0]?.href ?? '/about'; return (

{config.siteTitle}

{config.siteSubtitle && (

{config.siteSubtitle}

)} {t.home.enter}
); } // ── Typographic: no image, pure text ─────────────────────────── if (heroStyle === 'typographic') { return (

{config.siteTitle}

{config.siteSubtitle && (

{config.siteSubtitle}

)}
); } // ── Stacked: fullbleed image + text at bottom + thumbnail strip ─ if (heroStyle === 'stacked') { const allEntries = heroNavEntries(subpages, albums, t); return (

{config.siteTitle}

{config.siteSubtitle && (

{config.siteSubtitle}

)}
); } // ── Mosaic: multi-image grid with frosted title overlay ──────── if (heroStyle === 'mosaic') { return (
{heroData.slice(0, 4).map((img, i) => (
))}
); } // ── Minimal: pure text, no image, simple centered ────────────── if (heroStyle === 'minimal') { return (
); } // ── Fullbleed: full-viewport image + centered overlay ─────────── if (heroStyle === 'fullbleed') { return (
); } // ── Split: left text panel, right image (default) ─────────────── return (
{/* ── Left Panel ──────────────────────────────── */}
{/* ── Right Panel (Hero Carousel) ─────────────── */}
); }