feat: add thumbnail previews to admin panel for albums and hero images
- Add /api/admin/thumbnail/[id] proxy endpoint for serving Immich thumbnails - Show album cover thumbnails in AlbumCard components (subpages + standalone) - Show actual image thumbnails in AlbumPicker modal instead of emoji placeholders - Show small thumbnail previews for hero image entries - Add corresponding CSS styles for all thumbnail elements
This commit is contained in:
@@ -425,6 +425,14 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-item-thumb {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #222;
|
||||||
|
}
|
||||||
|
|
||||||
.hero-uuid {
|
.hero-uuid {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -455,6 +463,22 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0.6rem 0.75rem;
|
padding: 0.6rem 0.75rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-card-thumb {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-card-thumb img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.album-card-info {
|
.album-card-info {
|
||||||
@@ -735,6 +759,14 @@
|
|||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.picker-thumb-img {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #222;
|
||||||
|
}
|
||||||
|
|
||||||
.picker-item-info {
|
.picker-item-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -68,7 +68,12 @@ export default function AlbumPicker({ albums, onSelect, onClose, usedAlbumIds }:
|
|||||||
>
|
>
|
||||||
<div className="picker-item-thumb">
|
<div className="picker-item-thumb">
|
||||||
{album.thumbnailAssetId ? (
|
{album.thumbnailAssetId ? (
|
||||||
<div className="picker-thumb-placeholder">📷</div>
|
<img
|
||||||
|
className="picker-thumb-img"
|
||||||
|
src={`/api/admin/thumbnail/${album.thumbnailAssetId}`}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="picker-thumb-placeholder">📁</div>
|
<div className="picker-thumb-placeholder">📁</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -419,6 +419,11 @@ export default function PageBuilder() {
|
|||||||
return found?.assetCount || 0;
|
return found?.assetCount || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAlbumThumbnailId(id: string): string | null {
|
||||||
|
const found = immichAlbums.find((a) => a.id === id);
|
||||||
|
return found?.thumbnailAssetId || null;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Render ───────────────────────────────────────────────────
|
// ── Render ───────────────────────────────────────────────────
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -467,6 +472,12 @@ export default function PageBuilder() {
|
|||||||
)}
|
)}
|
||||||
{gallery.hero.map((id, i) => (
|
{gallery.hero.map((id, i) => (
|
||||||
<div key={i} className="hero-item">
|
<div key={i} className="hero-item">
|
||||||
|
<img
|
||||||
|
className="hero-item-thumb"
|
||||||
|
src={`/api/admin/thumbnail/${id}`}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
<span className="hero-uuid" title={id}>
|
<span className="hero-uuid" title={id}>
|
||||||
{id.slice(0, 8)}...{id.slice(-4)}
|
{id.slice(0, 8)}...{id.slice(-4)}
|
||||||
</span>
|
</span>
|
||||||
@@ -499,6 +510,7 @@ export default function PageBuilder() {
|
|||||||
album={album}
|
album={album}
|
||||||
name={getAlbumName(album.id)}
|
name={getAlbumName(album.id)}
|
||||||
count={getAlbumCount(album.id)}
|
count={getAlbumCount(album.id)}
|
||||||
|
thumbnailId={getAlbumThumbnailId(album.id)}
|
||||||
onRemove={() => removeStandaloneAlbum(i)}
|
onRemove={() => removeStandaloneAlbum(i)}
|
||||||
onUpdate={(updates) => {
|
onUpdate={(updates) => {
|
||||||
setGallery((g) => {
|
setGallery((g) => {
|
||||||
@@ -624,6 +636,7 @@ export default function PageBuilder() {
|
|||||||
album={album}
|
album={album}
|
||||||
name={getAlbumName(album.id)}
|
name={getAlbumName(album.id)}
|
||||||
count={getAlbumCount(album.id)}
|
count={getAlbumCount(album.id)}
|
||||||
|
thumbnailId={getAlbumThumbnailId(album.id)}
|
||||||
onRemove={() => removeSubpageAlbum(spIndex, aIndex)}
|
onRemove={() => removeSubpageAlbum(spIndex, aIndex)}
|
||||||
onUpdate={(updates) => {
|
onUpdate={(updates) => {
|
||||||
setGallery((g) => {
|
setGallery((g) => {
|
||||||
@@ -684,6 +697,7 @@ export default function PageBuilder() {
|
|||||||
album={album}
|
album={album}
|
||||||
name={getAlbumName(album.id)}
|
name={getAlbumName(album.id)}
|
||||||
count={getAlbumCount(album.id)}
|
count={getAlbumCount(album.id)}
|
||||||
|
thumbnailId={getAlbumThumbnailId(album.id)}
|
||||||
onRemove={() => removeSectionAlbum(spIndex, secIndex, aIndex)}
|
onRemove={() => removeSectionAlbum(spIndex, secIndex, aIndex)}
|
||||||
onUpdate={(updates) => {
|
onUpdate={(updates) => {
|
||||||
setGallery((g) => {
|
setGallery((g) => {
|
||||||
@@ -767,16 +781,26 @@ interface AlbumCardProps {
|
|||||||
album: AlbumEntry;
|
album: AlbumEntry;
|
||||||
name: string;
|
name: string;
|
||||||
count: number;
|
count: number;
|
||||||
|
thumbnailId: string | null;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onUpdate: (updates: Partial<AlbumEntry>) => void;
|
onUpdate: (updates: Partial<AlbumEntry>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlbumCard({ album, name, count, onRemove, onUpdate }: AlbumCardProps) {
|
function AlbumCard({ album, name, count, thumbnailId, onRemove, onUpdate }: AlbumCardProps) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="album-card">
|
<div className="album-card">
|
||||||
<div className="album-card-main">
|
<div className="album-card-main">
|
||||||
|
{thumbnailId && (
|
||||||
|
<div className="album-card-thumb">
|
||||||
|
<img
|
||||||
|
src={`/api/admin/thumbnail/${thumbnailId}`}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="album-card-info">
|
<div className="album-card-info">
|
||||||
<span className="album-card-name">{album.title || name}</span>
|
<span className="album-card-name">{album.title || name}</span>
|
||||||
<span className="album-card-count">{count} photos</span>
|
<span className="album-card-count">{count} photos</span>
|
||||||
|
|||||||
62
app/api/admin/thumbnail/[id]/route.ts
Normal file
62
app/api/admin/thumbnail/[id]/route.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Admin-only thumbnail proxy — serves small thumbnails for the admin panel
|
||||||
|
* using raw Immich asset UUIDs (no token encoding needed since admin is authenticated).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: 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: 'Not configured' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: assetId } = await params;
|
||||||
|
|
||||||
|
// Validate it looks like a UUID
|
||||||
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
if (!uuidRegex.test(assetId)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid asset ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${config.immich.apiUrl}/assets/${assetId}/thumbnail?size=thumbnail`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'x-api-key': config.immich.apiKey,
|
||||||
|
Accept: 'application/octet-stream',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json({ error: 'Asset not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = res.headers.get('content-type') || 'image/jpeg';
|
||||||
|
const body = res.body;
|
||||||
|
|
||||||
|
return new NextResponse(body, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Cache-Control': 'private, max-age=3600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Admin Thumbnail] Failed to fetch:', err);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch thumbnail' }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user