feat: per-album hero image – picker from album assets, fullbleed banner on detail page
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user