/** * 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, type ExifDisplayConfig } from '@/lib/config'; import { assetLocationPrecision } from '@/lib/assetLocation'; import { placeLabel } from '@/lib/mapPrecision'; import { imageUrl, assetPlaceholder } from '@/lib/urls'; import { HeroCarousel } from '@/components/HeroCarousel'; import { FadeIn } from '@/components/FadeIn'; import { getServerDictionary } from '@/lib/i18n/server'; import { loadSiteNav } from '@/lib/siteNav.server'; import type { SiteNavLink } from '@/lib/siteNav'; // Render at request time — requires live Immich connection export const dynamic = 'force-dynamic'; const pad2 = (n: number) => String(n).padStart(2, '0'); /** * Camera line for the hero chip: "Q3 · 28MM · ƒ/5.6 · 1/250 · ISO 200". * Returns undefined when the asset carries no usable EXIF. * * Honours the same `exif:` groups as the lightbox panel (`/api/exif`), and the * city passes the album's `location:` precision too — the chip is the most * visible EXIF on the site and must not publish what the owner switched off. */ async function heroExifLine( asset: ImmichAsset, show: ExifDisplayConfig, ): Promise { const e = asset.exifInfo; if (!e) return undefined; const city = show.location ? placeLabel(await assetLocationPrecision(asset.id), { city: e.city ?? '', country: '' }).city : ''; const parts = [ city || (show.camera ? e.model : undefined) || undefined, show.camera && e.focalLength ? `${Math.round(e.focalLength)}mm` : undefined, show.settings && e.fNumber ? `ƒ/${e.fNumber}` : undefined, show.settings && e.exposureTime ? `${e.exposureTime}s` : undefined, show.settings && e.iso ? `ISO ${e.iso}` : undefined, ].filter(Boolean); return parts.length > 0 ? parts.join(' · ') : undefined; } /** Shared nav links for hero sections — same entries and order as the header. */ function HeroNavLinks({ links }: { links: SiteNavLink[] }) { return ( <> {links.map((entry, i) => ( {entry.label} {entry.count && ( )} ))} ); } /** Title + optional subtitle + nav — shared by mosaic, minimal, fullbleed, split. */ function HeroTextContent({ title, subtitle, links, navLabel, }: { title: string; subtitle?: string; links: SiteNavLink[]; navLabel: string; }) { return ( <>

{title}

{subtitle && (

{subtitle}

)} ); } export default async function HomePage() { const config = getConfig(); const t = getServerDictionary(); if (config.needsSetup) { return null; } const links = await loadSiteNav(); // 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 ? await heroExifLine(asset, config.exif) : 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') { // No fallback: /about used to be hardcoded here and 404ed with About off. const enterHref = links[0]?.href; return (

{config.siteTitle}

{config.siteSubtitle && (

{config.siteSubtitle}

)} {enterHref && ( {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') { 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) ─────────────── */}
); }