feat: per-album hero image – picker from album assets, fullbleed banner on detail page
This commit is contained in:
@@ -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 && (
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
66
app/api/admin/albums/[albumId]/assets/route.ts
Normal file
66
app/api/admin/albums/[albumId]/assets/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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?: {
|
||||||
|
|||||||
Reference in New Issue
Block a user