From 19c3727afcdd5c0d9cc7792d2050d6d8616805ec Mon Sep 17 00:00:00 2001 From: ralksta Date: Sat, 30 May 2026 15:33:43 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20per-album=20hero=20image=20=E2=80=93=20?= =?UTF-8?q?picker=20from=20album=20assets,=20fullbleed=20banner=20on=20det?= =?UTF-8?q?ail=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/[...path]/AlbumDetailView.tsx | 21 +++++- app/[...path]/page.tsx | 24 +++++++ app/admin/admin.css | 50 ++++++++++++++ app/admin/components/AssetPicker.tsx | 42 ++++++++---- app/admin/components/PageBuilder.tsx | 64 ++++++++++++++++-- .../admin/albums/[albumId]/assets/route.ts | 66 +++++++++++++++++++ app/globals.css | 22 +++++++ lib/config/index.ts | 6 +- lib/config/schema.ts | 22 +++---- 9 files changed, 287 insertions(+), 30 deletions(-) create mode 100644 app/api/admin/albums/[albumId]/assets/route.ts diff --git a/app/[...path]/AlbumDetailView.tsx b/app/[...path]/AlbumDetailView.tsx index 4e31cd5..bb1fdaa 100644 --- a/app/[...path]/AlbumDetailView.tsx +++ b/app/[...path]/AlbumDetailView.tsx @@ -3,6 +3,7 @@ import type { PhotoItem } from './PhotoGrid'; import { PhotoGrid } from './PhotoGrid'; import { BackLink } from '@/components/BackLink'; import type { GridConfig } from '@/lib/config'; +import Image from 'next/image'; import React from 'react'; interface AlbumDetailViewProps { @@ -13,6 +14,8 @@ interface AlbumDetailViewProps { backLinkHref: string; backLinkLabel: string; subtitle?: string; + heroImageUrl?: string; + heroBlurDataURL?: string; } export function AlbumDetailView({ @@ -23,10 +26,26 @@ export function AlbumDetailView({ backLinkHref, backLinkLabel, subtitle, + heroImageUrl, + heroBlurDataURL, }: AlbumDetailViewProps) { return ( <> -
+ {heroImageUrl && ( +
+ {album.albumName} +
+
+ )} +

{album.albumName}

{subtitle && ( diff --git a/app/[...path]/page.tsx b/app/[...path]/page.tsx index c8c57cf..6fa8776 100644 --- a/app/[...path]/page.tsx +++ b/app/[...path]/page.tsx @@ -133,6 +133,21 @@ async function gateIfProtected( return ; } +/** Fetch hero image URL + blur placeholder for an album if configured. */ +async function getAlbumHeroData( + albumId: string, + config: ReturnType, +): Promise<{ heroImageUrl: string; heroBlurDataURL?: string } | null> { + const heroAssetId = config.albumHeroImages[albumId]; + if (!heroAssetId) return null; + const asset = await immich.getAssetInfo(heroAssetId); + const ph = asset ? assetPlaceholder(asset) : null; + return { + heroImageUrl: imageUrl(heroAssetId, 'preview'), + heroBlurDataURL: ph?.blurDataURL, + }; +} + export default async function PathPage({ params }: PathPageProps) { const { path } = await params; const config = getConfig(); @@ -178,6 +193,8 @@ export default async function PathPage({ params }: PathPageProps) { const albumGate = await gateIfProtected(album.id, 'album', album.albumName); if (albumGate) return albumGate; + const heroData = await getAlbumHeroData(album.id, config); + return ( ); } @@ -217,6 +235,8 @@ export default async function PathPage({ params }: PathPageProps) { const albumGate = await gateIfProtected(album.id, 'album', album.albumName); if (albumGate) return albumGate; + const heroData = await getAlbumHeroData(album.id, config); + return ( ); } @@ -265,6 +286,8 @@ export default async function PathPage({ params }: PathPageProps) { const albumGate = await gateIfProtected(album.id, 'album', album.albumName); if (albumGate) return albumGate; + const heroData = await getAlbumHeroData(album.id, config); + return ( ); } diff --git a/app/admin/admin.css b/app/admin/admin.css index 41da839..4af0bfe 100644 --- a/app/admin/admin.css +++ b/app/admin/admin.css @@ -631,6 +631,56 @@ background: var(--admin-bg); } +/* Album Hero Field */ +.album-hero-field { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.album-hero-preview { + position: relative; + width: 80px; + height: 54px; + border-radius: 6px; + overflow: hidden; + flex-shrink: 0; + border: 1px solid var(--admin-border); +} + +.album-hero-preview img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.album-hero-remove { + position: absolute; + top: 2px; + right: 2px; + width: 18px; + height: 18px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.65); + color: #fff; + border: none; + cursor: pointer; + font-size: 0.75rem; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.album-hero-empty { + font-size: 0.8rem; + color: var(--admin-text-muted); + font-style: italic; +} + .album-tile-uuid { margin-top: 0.5rem; } diff --git a/app/admin/components/AssetPicker.tsx b/app/admin/components/AssetPicker.tsx index 2951376..bbae474 100644 --- a/app/admin/components/AssetPicker.tsx +++ b/app/admin/components/AssetPicker.tsx @@ -13,12 +13,20 @@ interface Props { onSelect: (assetId: string) => void; onClose: () => void; currentAssetIds?: string[]; + albumId?: string; + title?: string; } -type Tab = 'favorites' | 'all'; +type Tab = 'album' | 'favorites' | 'all'; -export default function AssetPicker({ onSelect, onClose, currentAssetIds = [] }: Props) { - const [tab, setTab] = useState('favorites'); +export default function AssetPicker({ + onSelect, + onClose, + currentAssetIds = [], + albumId, + title, +}: Props) { + const [tab, setTab] = useState(albumId ? 'album' : 'favorites'); const [assets, setAssets] = useState([]); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); @@ -35,12 +43,16 @@ export default function AssetPicker({ onSelect, onClose, currentAssetIds = [] }: else setLoadingMore(true); try { - const params = new URLSearchParams({ - page: pageNum.toString(), - favorites: tab === 'favorites' ? 'true' : 'false', - }); - - const res = await fetch(`/api/admin/assets?${params}`); + let res: Response; + if (tab === 'album' && albumId) { + res = await fetch(`/api/admin/albums/${albumId}/assets`); + } else { + const params = new URLSearchParams({ + page: pageNum.toString(), + favorites: tab === 'favorites' ? 'true' : 'false', + }); + res = await fetch(`/api/admin/assets?${params}`); + } if (res.ok) { const data = await res.json(); setAssets((prev) => (append ? [...prev, ...data.assets] : data.assets)); @@ -54,7 +66,7 @@ export default function AssetPicker({ onSelect, onClose, currentAssetIds = [] }: setLoadingMore(false); } }, - [tab], + [tab, albumId], ); useEffect(() => { @@ -101,7 +113,7 @@ export default function AssetPicker({ onSelect, onClose, currentAssetIds = [] }:
e.stopPropagation()}>
-

Select Hero Image

+

{title || 'Select Hero Image'}

@@ -109,6 +121,14 @@ export default function AssetPicker({ onSelect, onClose, currentAssetIds = [] }: {/* Tabs */}
+ {albumId && ( + + )} +
+ ) : ( + No hero image set + )} + +
+
{album.id}
)} + {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?: {