feat: per-album hero image – picker from album assets, fullbleed banner on detail page

This commit is contained in:
ralksta
2026-05-30 15:33:43 +02:00
parent af5da8f316
commit 19c3727afc
9 changed files with 287 additions and 30 deletions

View File

@@ -3,6 +3,7 @@ import type { PhotoItem } from './PhotoGrid';
import { PhotoGrid } from './PhotoGrid'; import { PhotoGrid } from './PhotoGrid';
import { BackLink } from '@/components/BackLink'; import { BackLink } from '@/components/BackLink';
import type { GridConfig } from '@/lib/config'; import type { GridConfig } from '@/lib/config';
import Image from 'next/image';
import React from 'react'; import React from 'react';
interface AlbumDetailViewProps { interface AlbumDetailViewProps {
@@ -13,6 +14,8 @@ interface AlbumDetailViewProps {
backLinkHref: string; backLinkHref: string;
backLinkLabel: string; backLinkLabel: string;
subtitle?: string; subtitle?: string;
heroImageUrl?: string;
heroBlurDataURL?: string;
} }
export function AlbumDetailView({ export function AlbumDetailView({
@@ -23,10 +26,26 @@ export function AlbumDetailView({
backLinkHref, backLinkHref,
backLinkLabel, backLinkLabel,
subtitle, subtitle,
heroImageUrl,
heroBlurDataURL,
}: AlbumDetailViewProps) { }: AlbumDetailViewProps) {
return ( return (
<> <>
<div className="album-header"> {heroImageUrl && (
<div className="album-hero">
<Image
src={heroImageUrl}
alt={album.albumName}
fill
priority
sizes="100vw"
style={{ objectFit: 'cover' }}
{...(heroBlurDataURL ? { placeholder: 'blur' as const, blurDataURL: heroBlurDataURL } : {})}
/>
<div className="album-hero__overlay" />
</div>
)}
<div className={`album-header${heroImageUrl ? ' album-header--has-hero' : ''}`}>
<BackLink href={backLinkHref} label={backLinkLabel} /> <BackLink href={backLinkHref} label={backLinkLabel} />
<h1 className="album-header__title">{album.albumName}</h1> <h1 className="album-header__title">{album.albumName}</h1>
{subtitle && ( {subtitle && (

View File

@@ -133,6 +133,21 @@ async function gateIfProtected(
return <PasswordGate slug={key} title={title} type={type} />; return <PasswordGate slug={key} title={title} type={type} />;
} }
/** Fetch hero image URL + blur placeholder for an album if configured. */
async function getAlbumHeroData(
albumId: string,
config: ReturnType<typeof getConfig>,
): 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) { export default async function PathPage({ params }: PathPageProps) {
const { path } = await params; const { path } = await params;
const config = getConfig(); const config = getConfig();
@@ -178,6 +193,8 @@ export default async function PathPage({ params }: PathPageProps) {
const albumGate = await gateIfProtected(album.id, 'album', album.albumName); const albumGate = await gateIfProtected(album.id, 'album', album.albumName);
if (albumGate) return albumGate; if (albumGate) return albumGate;
const heroData = await getAlbumHeroData(album.id, config);
return ( return (
<AlbumDetailView <AlbumDetailView
album={album} album={album}
@@ -186,6 +203,7 @@ export default async function PathPage({ params }: PathPageProps) {
gridStyle={buildGridStyle(spGrid)} gridStyle={buildGridStyle(spGrid)}
backLinkHref={`/${subpageSlug}`} backLinkHref={`/${subpageSlug}`}
backLinkLabel={`Back to ${subpageName}`} backLinkLabel={`Back to ${subpageName}`}
{...heroData}
/> />
); );
} }
@@ -217,6 +235,8 @@ export default async function PathPage({ params }: PathPageProps) {
const albumGate = await gateIfProtected(album.id, 'album', album.albumName); const albumGate = await gateIfProtected(album.id, 'album', album.albumName);
if (albumGate) return albumGate; if (albumGate) return albumGate;
const heroData = await getAlbumHeroData(album.id, config);
return ( return (
<AlbumDetailView <AlbumDetailView
album={album} album={album}
@@ -226,6 +246,7 @@ export default async function PathPage({ params }: PathPageProps) {
subtitle={result.subpage.subtitle} subtitle={result.subpage.subtitle}
backLinkHref="/" backLinkHref="/"
backLinkLabel="Back to Gallery" backLinkLabel="Back to Gallery"
{...heroData}
/> />
); );
} }
@@ -265,6 +286,8 @@ export default async function PathPage({ params }: PathPageProps) {
const albumGate = await gateIfProtected(album.id, 'album', album.albumName); const albumGate = await gateIfProtected(album.id, 'album', album.albumName);
if (albumGate) return albumGate; if (albumGate) return albumGate;
const heroData = await getAlbumHeroData(album.id, config);
return ( return (
<AlbumDetailView <AlbumDetailView
album={album} album={album}
@@ -273,6 +296,7 @@ export default async function PathPage({ params }: PathPageProps) {
gridStyle={buildGridStyle()} gridStyle={buildGridStyle()}
backLinkHref="/" backLinkHref="/"
backLinkLabel="Back to Gallery" backLinkLabel="Back to Gallery"
{...heroData}
/> />
); );
} }

View File

@@ -631,6 +631,56 @@
background: var(--admin-bg); 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 { .album-tile-uuid {
margin-top: 0.5rem; margin-top: 0.5rem;
} }

View File

@@ -13,12 +13,20 @@ interface Props {
onSelect: (assetId: string) => void; onSelect: (assetId: string) => void;
onClose: () => void; onClose: () => void;
currentAssetIds?: string[]; currentAssetIds?: string[];
albumId?: string;
title?: string;
} }
type Tab = 'favorites' | 'all'; type Tab = 'album' | 'favorites' | 'all';
export default function AssetPicker({ onSelect, onClose, currentAssetIds = [] }: Props) { export default function AssetPicker({
const [tab, setTab] = useState<Tab>('favorites'); onSelect,
onClose,
currentAssetIds = [],
albumId,
title,
}: Props) {
const [tab, setTab] = useState<Tab>(albumId ? 'album' : 'favorites');
const [assets, setAssets] = useState<AssetInfo[]>([]); const [assets, setAssets] = useState<AssetInfo[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
@@ -35,12 +43,16 @@ export default function AssetPicker({ onSelect, onClose, currentAssetIds = [] }:
else setLoadingMore(true); else setLoadingMore(true);
try { try {
let res: Response;
if (tab === 'album' && albumId) {
res = await fetch(`/api/admin/albums/${albumId}/assets`);
} else {
const params = new URLSearchParams({ const params = new URLSearchParams({
page: pageNum.toString(), page: pageNum.toString(),
favorites: tab === 'favorites' ? 'true' : 'false', favorites: tab === 'favorites' ? 'true' : 'false',
}); });
res = await fetch(`/api/admin/assets?${params}`);
const res = await fetch(`/api/admin/assets?${params}`); }
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setAssets((prev) => (append ? [...prev, ...data.assets] : data.assets)); setAssets((prev) => (append ? [...prev, ...data.assets] : data.assets));
@@ -54,7 +66,7 @@ export default function AssetPicker({ onSelect, onClose, currentAssetIds = [] }:
setLoadingMore(false); setLoadingMore(false);
} }
}, },
[tab], [tab, albumId],
); );
useEffect(() => { useEffect(() => {
@@ -101,7 +113,7 @@ export default function AssetPicker({ onSelect, onClose, currentAssetIds = [] }:
<div className="picker-overlay" onClick={onClose}> <div className="picker-overlay" onClick={onClose}>
<div className="asset-picker-modal" onClick={(e) => e.stopPropagation()}> <div className="asset-picker-modal" onClick={(e) => e.stopPropagation()}>
<div className="picker-header"> <div className="picker-header">
<h3>Select Hero Image</h3> <h3>{title || 'Select Hero Image'}</h3>
<button className="admin-btn-icon" onClick={onClose}> <button className="admin-btn-icon" onClick={onClose}>
× ×
</button> </button>
@@ -109,6 +121,14 @@ export default function AssetPicker({ onSelect, onClose, currentAssetIds = [] }:
{/* Tabs */} {/* Tabs */}
<div className="asset-picker-tabs"> <div className="asset-picker-tabs">
{albumId && (
<button
className={`asset-picker-tab ${tab === 'album' ? 'active' : ''}`}
onClick={() => setTab('album')}
>
📂 This Album
</button>
)}
<button <button
className={`asset-picker-tab ${tab === 'favorites' ? 'active' : ''}`} className={`asset-picker-tab ${tab === 'favorites' ? 'active' : ''}`}
onClick={() => setTab('favorites')} onClick={() => setTab('favorites')}

View File

@@ -28,6 +28,7 @@ interface AlbumEntry {
title?: string; title?: string;
description?: string; description?: string;
password?: string; password?: string;
heroImage?: string;
} }
interface Section { interface Section {
@@ -66,7 +67,11 @@ interface ImmichAlbumInfo {
function parseAlbumEntries( function parseAlbumEntries(
raw: raw:
| Array< | Array<
string | Record<string, string | { title: string; description?: string; password?: string }> | string
| Record<
string,
string | { title: string; description?: string; password?: string; heroImage?: string }
>
> >
| undefined, | undefined,
): AlbumEntry[] { ): AlbumEntry[] {
@@ -75,25 +80,33 @@ function parseAlbumEntries(
if (typeof entry === 'string') return { id: entry }; if (typeof entry === 'string') return { id: entry };
const [id, value] = Object.entries(entry)[0]; const [id, value] = Object.entries(entry)[0];
if (typeof value === 'string') return { id, title: value }; if (typeof value === 'string') return { id, title: value };
return { id, title: value.title, description: value.description, password: value.password }; return {
id,
title: value.title,
description: value.description,
password: value.password,
heroImage: value.heroImage,
};
}); });
} }
function serializeAlbumEntries( function serializeAlbumEntries(
entries: AlbumEntry[], entries: AlbumEntry[],
): Array< ): Array<
string | Record<string, string | { title: string; description?: string; password?: string }> | string
| Record<string, string | { title: string; description?: string; password?: string; heroImage?: string }>
> { > {
return entries.map((entry) => { return entries.map((entry) => {
if (!entry.title && !entry.description && !entry.password) return entry.id; if (!entry.title && !entry.description && !entry.password && !entry.heroImage) return entry.id;
if (entry.title && !entry.description && !entry.password) { if (entry.title && !entry.description && !entry.password && !entry.heroImage) {
return { [entry.id]: entry.title }; return { [entry.id]: entry.title };
} }
const val: { title: string; description?: string; password?: string } = { const val: { title: string; description?: string; password?: string; heroImage?: string } = {
title: entry.title || '', title: entry.title || '',
}; };
if (entry.description) val.description = entry.description; if (entry.description) val.description = entry.description;
if (entry.password) val.password = entry.password; if (entry.password) val.password = entry.password;
if (entry.heroImage) val.heroImage = entry.heroImage;
return { [entry.id]: val }; return { [entry.id]: val };
}); });
} }
@@ -1160,6 +1173,7 @@ function AlbumCard({
dragListeners, dragListeners,
}: AlbumCardProps) { }: AlbumCardProps) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [showHeroPicker, setShowHeroPicker] = useState(false);
return ( return (
<div className="album-tile"> <div className="album-tile">
@@ -1218,11 +1232,49 @@ function AlbumCard({
placeholder="Leave empty for public" placeholder="Leave empty for public"
/> />
</div> </div>
{/* Hero Image */}
<div className="admin-field">
<label>Hero Image</label>
<div className="album-hero-field">
{album.heroImage ? (
<div className="album-hero-preview">
<img src={`/api/admin/thumbnail/${album.heroImage}`} alt="Hero" />
<button
className="album-hero-remove"
onClick={() => onUpdate({ heroImage: undefined })}
title="Remove hero image"
>
×
</button>
</div>
) : (
<span className="album-hero-empty">No hero image set</span>
)}
<button
className="admin-btn admin-btn-xs"
onClick={() => setShowHeroPicker(true)}
>
{album.heroImage ? 'Change' : '+ Pick'}
</button>
</div>
</div>
<div className="album-tile-uuid"> <div className="album-tile-uuid">
<code>{album.id}</code> <code>{album.id}</code>
</div> </div>
</div> </div>
)} )}
{showHeroPicker && (
<AssetPicker
albumId={album.id}
onSelect={(assetId) => {
onUpdate({ heroImage: assetId });
setShowHeroPicker(false);
}}
onClose={() => setShowHeroPicker(false)}
currentAssetIds={album.heroImage ? [album.heroImage] : []}
title="Pick Hero Image for Album"
/>
)}
</div> </div>
); );
} }

View File

@@ -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 });
}
}

View File

@@ -816,10 +816,32 @@
} }
/* ── Photo Grid (Album Detail) ────────────────────── */ /* ── 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 { .album-header {
margin-bottom: 40px; margin-bottom: 40px;
} }
.album-header--has-hero {
margin-top: -60px;
position: relative;
z-index: 1;
}
.album-header__back { .album-header__back {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View File

@@ -102,6 +102,7 @@ export function getConfig(): AppConfig {
albumOverrides: {}, albumOverrides: {},
albumDescriptions: {}, albumDescriptions: {},
albumPasswords: {}, albumPasswords: {},
albumHeroImages: {},
cacheTtl: env.CACHE_TTL * 1000, cacheTtl: env.CACHE_TTL * 1000,
rateLimitRpm: env.RATE_LIMIT_RPM, rateLimitRpm: env.RATE_LIMIT_RPM,
trustedProxies: env.TRUSTED_PROXIES, trustedProxies: env.TRUSTED_PROXIES,
@@ -115,11 +116,12 @@ export function getConfig(): AppConfig {
const albumOverrides: Record<string, string> = {}; const albumOverrides: Record<string, string> = {};
const albumDescriptions: Record<string, string> = {}; const albumDescriptions: Record<string, string> = {};
const albumPasswords: Record<string, string> = {}; const albumPasswords: Record<string, string> = {};
const albumHeroImages: Record<string, string> = {};
function processAlbumEntry( function processAlbumEntry(
entry: entry:
| string | string
| Record<string, string | { title: string; description?: string; password?: string }>, | Record<string, string | { title: string; description?: string; password?: string; heroImage?: string }>,
context: string, context: string,
): string { ): string {
if (typeof entry === 'string') { if (typeof entry === 'string') {
@@ -134,6 +136,7 @@ export function getConfig(): AppConfig {
if (value.title) albumOverrides[validatedUuid] = value.title; if (value.title) albumOverrides[validatedUuid] = value.title;
if (value.description) albumDescriptions[validatedUuid] = value.description; if (value.description) albumDescriptions[validatedUuid] = value.description;
if (value.password) albumPasswords[validatedUuid] = value.password; if (value.password) albumPasswords[validatedUuid] = value.password;
if (value.heroImage) albumHeroImages[validatedUuid] = value.heroImage;
} }
return validatedUuid; return validatedUuid;
} }
@@ -283,6 +286,7 @@ export function getConfig(): AppConfig {
albumOverrides, albumOverrides,
albumDescriptions, albumDescriptions,
albumPasswords, albumPasswords,
albumHeroImages,
cacheTtl: env.CACHE_TTL * 1000, cacheTtl: env.CACHE_TTL * 1000,
rateLimitRpm: env.RATE_LIMIT_RPM, rateLimitRpm: env.RATE_LIMIT_RPM,
trustedProxies: env.TRUSTED_PROXIES, trustedProxies: env.TRUSTED_PROXIES,

View File

@@ -97,34 +97,34 @@ export interface AppConfig {
albumOverrides: Record<string, string>; albumOverrides: Record<string, string>;
albumDescriptions: Record<string, string>; albumDescriptions: Record<string, string>;
albumPasswords: Record<string, string>; albumPasswords: Record<string, string>;
albumHeroImages: Record<string, string>;
cacheTtl: number; cacheTtl: number;
rateLimitRpm: number; rateLimitRpm: number;
trustedProxies: string[]; trustedProxies: string[];
needsSetup?: boolean; needsSetup?: boolean;
} }
export interface AlbumEntryObject {
title: string;
description?: string;
password?: string;
heroImage?: string;
}
export interface GalleryYaml { export interface GalleryYaml {
hero?: string | string[]; hero?: string | string[];
albums?: Array< albums?: Array<string | Record<string, string | AlbumEntryObject>>;
string | Record<string, string | { title: string; description?: string; password?: string }>
>;
subpages?: subpages?:
| Record<string, string[] | Array<string | Record<string, string>>> | Record<string, string[] | Array<string | Record<string, string>>>
| Array<{ | Array<{
name: string; name: string;
title?: string; title?: string;
subtitle?: string; subtitle?: string;
albums?: Array< albums?: Array<string | Record<string, string | AlbumEntryObject>>;
| string
| Record<string, string | { title: string; description?: string; password?: string }>
>;
sections?: Array<{ sections?: Array<{
title: string; title: string;
description?: string; description?: string;
albums: Array< albums: Array<string | Record<string, string | AlbumEntryObject>>;
| string
| Record<string, string | { title: string; description?: string; password?: string }>
>;
}>; }>;
password?: string; password?: string;
grid?: { grid?: {