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

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