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 { 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 (
<>
<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} />
<h1 className="album-header__title">{album.albumName}</h1>
{subtitle && (

View File

@@ -133,6 +133,21 @@ async function gateIfProtected(
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) {
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 (
<AlbumDetailView
album={album}
@@ -186,6 +203,7 @@ export default async function PathPage({ params }: PathPageProps) {
gridStyle={buildGridStyle(spGrid)}
backLinkHref={`/${subpageSlug}`}
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);
if (albumGate) return albumGate;
const heroData = await getAlbumHeroData(album.id, config);
return (
<AlbumDetailView
album={album}
@@ -226,6 +246,7 @@ export default async function PathPage({ params }: PathPageProps) {
subtitle={result.subpage.subtitle}
backLinkHref="/"
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);
if (albumGate) return albumGate;
const heroData = await getAlbumHeroData(album.id, config);
return (
<AlbumDetailView
album={album}
@@ -273,6 +296,7 @@ export default async function PathPage({ params }: PathPageProps) {
gridStyle={buildGridStyle()}
backLinkHref="/"
backLinkLabel="Back to Gallery"
{...heroData}
/>
);
}

View File

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

View File

@@ -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<Tab>('favorites');
export default function AssetPicker({
onSelect,
onClose,
currentAssetIds = [],
albumId,
title,
}: Props) {
const [tab, setTab] = useState<Tab>(albumId ? 'album' : 'favorites');
const [assets, setAssets] = useState<AssetInfo[]>([]);
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 = [] }:
<div className="picker-overlay" onClick={onClose}>
<div className="asset-picker-modal" onClick={(e) => e.stopPropagation()}>
<div className="picker-header">
<h3>Select Hero Image</h3>
<h3>{title || 'Select Hero Image'}</h3>
<button className="admin-btn-icon" onClick={onClose}>
×
</button>
@@ -109,6 +121,14 @@ export default function AssetPicker({ onSelect, onClose, currentAssetIds = [] }:
{/* Tabs */}
<div className="asset-picker-tabs">
{albumId && (
<button
className={`asset-picker-tab ${tab === 'album' ? 'active' : ''}`}
onClick={() => setTab('album')}
>
📂 This Album
</button>
)}
<button
className={`asset-picker-tab ${tab === 'favorites' ? 'active' : ''}`}
onClick={() => setTab('favorites')}

View File

@@ -28,6 +28,7 @@ interface AlbumEntry {
title?: string;
description?: string;
password?: string;
heroImage?: string;
}
interface Section {
@@ -66,7 +67,11 @@ interface ImmichAlbumInfo {
function parseAlbumEntries(
raw:
| Array<
string | Record<string, string | { title: string; description?: string; password?: string }>
| string
| Record<
string,
string | { title: string; description?: string; password?: string; heroImage?: string }
>
>
| undefined,
): AlbumEntry[] {
@@ -75,25 +80,33 @@ function parseAlbumEntries(
if (typeof entry === 'string') return { id: entry };
const [id, value] = Object.entries(entry)[0];
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(
entries: AlbumEntry[],
): 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) => {
if (!entry.title && !entry.description && !entry.password) return entry.id;
if (entry.title && !entry.description && !entry.password) {
if (!entry.title && !entry.description && !entry.password && !entry.heroImage) return entry.id;
if (entry.title && !entry.description && !entry.password && !entry.heroImage) {
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 || '',
};
if (entry.description) val.description = entry.description;
if (entry.password) val.password = entry.password;
if (entry.heroImage) val.heroImage = entry.heroImage;
return { [entry.id]: val };
});
}
@@ -1160,6 +1173,7 @@ function AlbumCard({
dragListeners,
}: AlbumCardProps) {
const [expanded, setExpanded] = useState(false);
const [showHeroPicker, setShowHeroPicker] = useState(false);
return (
<div className="album-tile">
@@ -1218,11 +1232,49 @@ function AlbumCard({
placeholder="Leave empty for public"
/>
</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">
<code>{album.id}</code>
</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>
);
}

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) ────────────────────── */
/* ── 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;