e.stopPropagation()}>
-
Select Hero Image
+
{title || 'Select Hero Image'}
@@ -109,6 +121,14 @@ export default function AssetPicker({ onSelect, onClose, currentAssetIds = [] }:
{/* Tabs */}
+ {albumId && (
+
+ )}
)}
+ {showHeroPicker && (
+
{
+ onUpdate({ heroImage: assetId });
+ setShowHeroPicker(false);
+ }}
+ onClose={() => setShowHeroPicker(false)}
+ currentAssetIds={album.heroImage ? [album.heroImage] : []}
+ title="Pick Hero Image for Album"
+ />
+ )}
);
}
diff --git a/app/api/admin/albums/[albumId]/assets/route.ts b/app/api/admin/albums/[albumId]/assets/route.ts
new file mode 100644
index 0000000..aa75913
--- /dev/null
+++ b/app/api/admin/albums/[albumId]/assets/route.ts
@@ -0,0 +1,66 @@
+/**
+ * Admin API: Get assets of a specific album for the hero image picker.
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
+import { getConfig } from '@/lib/config';
+
+interface ImmichAlbumDetail {
+ assets: Array<{
+ id: string;
+ type: string;
+ originalFileName: string;
+ thumbhash: string | null;
+ fileCreatedAt: string;
+ isFavorite: boolean;
+ }>;
+}
+
+export async function GET(
+ _request: NextRequest,
+ { params }: { params: Promise<{ albumId: string }> },
+) {
+ if (!isAdminEnabled()) {
+ return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
+ }
+ if (!(await isAdminAuthenticated())) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const config = getConfig();
+ if (config.needsSetup) {
+ return NextResponse.json({ error: 'Immich not configured' }, { status: 503 });
+ }
+
+ const { albumId } = await params;
+
+ try {
+ const res = await fetch(`${config.immich.apiUrl}/albums/${albumId}`, {
+ headers: {
+ 'x-api-key': config.immich.apiKey,
+ Accept: 'application/json',
+ },
+ });
+
+ if (!res.ok) {
+ return NextResponse.json({ error: `Immich API returned ${res.status}` }, { status: 502 });
+ }
+
+ const album = (await res.json()) as ImmichAlbumDetail;
+
+ const assets = album.assets
+ .filter((a) => a.type === 'IMAGE')
+ .map((a) => ({
+ id: a.id,
+ originalFileName: a.originalFileName,
+ fileCreatedAt: a.fileCreatedAt,
+ isFavorite: a.isFavorite,
+ }));
+
+ return NextResponse.json({ assets, nextPage: null });
+ } catch (err) {
+ console.error('Failed to fetch album assets:', err);
+ return NextResponse.json({ error: 'Failed to fetch album assets' }, { status: 500 });
+ }
+}
diff --git a/app/globals.css b/app/globals.css
index cdd1160..67b610c 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -816,10 +816,32 @@
}
/* ── Photo Grid (Album Detail) ────────────────────── */
+/* ── Album Hero Image ────────────────────────────────────────── */
+.album-hero {
+ position: relative;
+ width: 100%;
+ height: clamp(240px, 40vw, 520px);
+ overflow: hidden;
+ margin-bottom: 0;
+}
+
+.album-hero__overlay {
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(to bottom, transparent 40%, var(--bg-primary, #fff) 100%);
+ pointer-events: none;
+}
+
.album-header {
margin-bottom: 40px;
}
+.album-header--has-hero {
+ margin-top: -60px;
+ position: relative;
+ z-index: 1;
+}
+
.album-header__back {
display: inline-flex;
align-items: center;
diff --git a/lib/config/index.ts b/lib/config/index.ts
index 6ccb84a..0ef1f72 100644
--- a/lib/config/index.ts
+++ b/lib/config/index.ts
@@ -102,6 +102,7 @@ export function getConfig(): AppConfig {
albumOverrides: {},
albumDescriptions: {},
albumPasswords: {},
+ albumHeroImages: {},
cacheTtl: env.CACHE_TTL * 1000,
rateLimitRpm: env.RATE_LIMIT_RPM,
trustedProxies: env.TRUSTED_PROXIES,
@@ -115,11 +116,12 @@ export function getConfig(): AppConfig {
const albumOverrides: Record
= {};
const albumDescriptions: Record = {};
const albumPasswords: Record = {};
+ const albumHeroImages: Record = {};
function processAlbumEntry(
entry:
| string
- | Record,
+ | Record,
context: string,
): string {
if (typeof entry === 'string') {
@@ -134,6 +136,7 @@ export function getConfig(): AppConfig {
if (value.title) albumOverrides[validatedUuid] = value.title;
if (value.description) albumDescriptions[validatedUuid] = value.description;
if (value.password) albumPasswords[validatedUuid] = value.password;
+ if (value.heroImage) albumHeroImages[validatedUuid] = value.heroImage;
}
return validatedUuid;
}
@@ -283,6 +286,7 @@ export function getConfig(): AppConfig {
albumOverrides,
albumDescriptions,
albumPasswords,
+ albumHeroImages,
cacheTtl: env.CACHE_TTL * 1000,
rateLimitRpm: env.RATE_LIMIT_RPM,
trustedProxies: env.TRUSTED_PROXIES,
diff --git a/lib/config/schema.ts b/lib/config/schema.ts
index 2a01f7d..8c44d17 100644
--- a/lib/config/schema.ts
+++ b/lib/config/schema.ts
@@ -97,34 +97,34 @@ export interface AppConfig {
albumOverrides: Record;
albumDescriptions: Record;
albumPasswords: Record;
+ albumHeroImages: Record;
cacheTtl: number;
rateLimitRpm: number;
trustedProxies: string[];
needsSetup?: boolean;
}
+export interface AlbumEntryObject {
+ title: string;
+ description?: string;
+ password?: string;
+ heroImage?: string;
+}
+
export interface GalleryYaml {
hero?: string | string[];
- albums?: Array<
- string | Record
- >;
+ albums?: Array>;
subpages?:
| Record>>
| Array<{
name: string;
title?: string;
subtitle?: string;
- albums?: Array<
- | string
- | Record
- >;
+ albums?: Array>;
sections?: Array<{
title: string;
description?: string;
- albums: Array<
- | string
- | Record
- >;
+ albums: Array>;
}>;
password?: string;
grid?: {