feat: image optimization with multiple sizes and lazy loading

- Generate thumbnail (150px), medium (800px), full size on upload
- OptimizedImage component with Intersection Observer lazy loading
- Blur placeholder while loading
- Fetches appropriate size based on props (thumbnail/medium/full)
- Async image loading reduces initial page load time
- Added medium and thumbnail columns to images table
This commit is contained in:
2026-06-30 23:34:23 -05:00
parent 73be5d2f22
commit 81c12ace7b
5 changed files with 196 additions and 25 deletions
+39 -10
View File
@@ -3,17 +3,46 @@ import { requireRole } from '@/lib/auth';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import sharp from 'sharp'; import sharp from 'sharp';
function generateThumbnail(base64Data: string, width = 300, height = 200): Promise<string> { interface ImageSizes {
thumbnail: string;
medium: string;
data: string;
}
async function generateImageSizes(base64Data: string): Promise<ImageSizes> {
const matches = base64Data.match(/^data:image\/(\w+);base64,(.+)$/); const matches = base64Data.match(/^data:image\/(\w+);base64,(.+)$/);
if (!matches) return Promise.resolve(base64Data); if (!matches) {
return { thumbnail: base64Data, medium: base64Data, data: base64Data };
}
const ext = matches[1]; const ext = matches[1];
const buffer = Buffer.from(matches[2], 'base64'); const buffer = Buffer.from(matches[2], 'base64');
return sharp(buffer) // Convert to WebP for better compression
.resize(width, height, { fit: 'cover' }) const toBase64 = (buf: Buffer) => `data:image/webp;base64,${buf.toString('base64')}`;
.toBuffer()
.then(resized => `data:image/${ext};base64,${resized.toString('base64')}`); // Generate multiple sizes in parallel
const [thumbnail, medium] = await Promise.all([
sharp(buffer)
.resize(400, 300, { fit: 'cover' })
.webp({ quality: 80 })
.toBuffer(),
sharp(buffer)
.resize(800, 600, { fit: 'inside', withoutEnlargement: true })
.webp({ quality: 85 })
.toBuffer(),
]);
// Also convert full image to WebP
const fullImage = await sharp(buffer)
.webp({ quality: 90 })
.toBuffer();
return {
thumbnail: toBase64(thumbnail),
medium: toBase64(medium),
data: toBase64(fullImage),
};
} }
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
@@ -62,12 +91,12 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing filename or data' }, { status: 400 }); return NextResponse.json({ error: 'Missing filename or data' }, { status: 400 });
} }
// Generate thumbnail for faster loading // Generate multiple sizes for responsive images (thumbnail, medium, full)
const thumbnail = await generateThumbnail(data, 400, 300); const sizes = await generateImageSizes(data);
const { rows } = await db.query( const { rows } = await db.query(
'INSERT INTO images (filename, data, thumbnail, category, room_id, location_target) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, filename, data, thumbnail, category, room_id, location_target, uploaded_at', 'INSERT INTO images (filename, data, thumbnail, medium, category, room_id, location_target) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, filename, category, room_id, location_target, uploaded_at',
[filename, data, thumbnail, category || 'other', room_id || null, location_target || 'gallery'] [filename, sizes.data, sizes.thumbnail, sizes.medium, category || 'other', room_id || null, location_target || 'gallery']
); );
return NextResponse.json({ data: rows[0] }); return NextResponse.json({ data: rows[0] });
+14 -5
View File
@@ -6,12 +6,19 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const id = searchParams.get('id'); const id = searchParams.get('id');
const thumb = searchParams.get('thumb'); // ?thumb=1 for thumbnail const thumb = searchParams.get('thumb'); // ?thumb=1 for thumbnail
const medium = searchParams.get('medium'); // ?medium=1 for medium size
if (!id) { if (!id) {
return NextResponse.json({ error: 'Missing id' }, { status: 400 }); return NextResponse.json({ error: 'Missing id' }, { status: 400 });
} }
const { rows } = await db.query('SELECT data FROM images WHERE id = $1', [id]); // Select appropriate column based on size
const column = thumb ? 'thumbnail' : medium ? 'medium' : 'data';
const { rows } = await db.query(
`SELECT ${column} as data FROM images WHERE id = $1`,
[id]
);
if (!rows.length) { if (!rows.length) {
return NextResponse.json({ error: 'Not found' }, { status: 404 }); return NextResponse.json({ error: 'Not found' }, { status: 404 });
@@ -19,10 +26,12 @@ export async function GET(request: NextRequest) {
const imageData = rows[0].data; const imageData = rows[0].data;
// If thumbnail requested and we have thumbnail data stored if (!imageData) {
if (thumb) { // Fallback to full image if variant doesn't exist
// For now, return the full image (thumbnail generation happens on upload) const { rows: fallback } = await db.query('SELECT data FROM images WHERE id = $1', [id]);
// Future: store thumbnail separately in thumbnail column if (!fallback.length || !fallback[0].data) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
} }
// Parse base64 data // Parse base64 data
+14 -10
View File
@@ -4,6 +4,7 @@ import { useEffect, useState, useRef } from 'react';
import { formatDisplayDate } from '@/lib/date'; import { formatDisplayDate } from '@/lib/date';
import RoomCalendar from '@/components/RoomCalendar'; import RoomCalendar from '@/components/RoomCalendar';
import LikeButton from '@/components/LikeButton'; import LikeButton from '@/components/LikeButton';
import OptimizedImage from '@/components/OptimizedImage';
interface Room { interface Room {
id: number; id: number;
@@ -315,17 +316,14 @@ export default function RoomsPage() {
onMouseLeave={(e) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(1,13,30,0.08)'; }} onMouseLeave={(e) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(1,13,30,0.08)'; }}
> >
{/* ─── Main Image ─── */} {/* ─── Main Image ─── */}
<div style={{ position: 'relative', width: '100%' }}> <div style={{ position: 'relative', width: '100%', height: '300px' }}>
{activeSrc ? ( {activeSrc ? (
<img <OptimizedImage
src={activeSrc} id={parseInt(activeSrc.split('id=')[1])}
alt={room.name} alt={room.name}
style={{ size="medium"
width: '100%', lazy={true}
height: '300px', style={{ width: '100%', height: '300px' }}
objectFit: 'cover',
display: 'block',
}}
/> />
) : ( ) : (
<div style={{ width: '100%', height: '300px', background: '#ddd', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999' }}> <div style={{ width: '100%', height: '300px', background: '#ddd', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999' }}>
@@ -409,7 +407,13 @@ export default function RoomsPage() {
}} }}
> >
{p ? ( {p ? (
<img src={p} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> <OptimizedImage
id={parseInt(p.split('id=')[1])}
alt=""
size="thumbnail"
lazy={true}
style={{ width: '100%', height: '100%' }}
/>
) : ( ) : (
<div style={{ width: '100%', height: '100%', background: '#ddd' }} /> <div style={{ width: '100%', height: '100%', background: '#ddd' }} />
)} )}
+123
View File
@@ -0,0 +1,123 @@
'use client';
import { useState, useEffect, useRef } from 'react';
interface OptimizedImageProps {
id: number;
alt: string;
style?: React.CSSProperties;
className?: string;
size?: 'thumbnail' | 'medium' | 'full';
lazy?: boolean;
placeholder?: boolean;
}
export default function OptimizedImage({
id,
alt,
style,
className,
size = 'medium',
lazy = true,
placeholder = true,
}: OptimizedImageProps) {
const [loaded, setLoaded] = useState(false);
const [inView, setInView] = useState(!lazy);
const [error, setError] = useState(false);
const imgRef = useRef<HTMLDivElement>(null);
// Intersection Observer for lazy loading
useEffect(() => {
if (!lazy || !imgRef.current) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setInView(true);
observer.disconnect();
}
},
{ rootMargin: '50px' }
);
observer.observe(imgRef.current);
return () => observer.disconnect();
}, [lazy]);
const getUrl = () => {
const base = `/api/image?id=${id}`;
if (size === 'thumbnail') return `${base}&thumb=1`;
if (size === 'medium') return `${base}&medium=1`;
return base;
};
return (
<div
ref={imgRef}
className={className}
style={{
position: 'relative',
overflow: 'hidden',
background: placeholder ? '#f0f0f0' : undefined,
...style,
}}
>
{/* Placeholder shimmer */}
{placeholder && !loaded && !error && (
<div
style={{
position: 'absolute',
inset: 0,
background: 'linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)',
backgroundSize: '200% 100%',
animation: 'shimmer 1.5s infinite',
}}
/>
)}
{/* Error state */}
{error && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#f5f5f5',
color: '#999',
fontSize: '14px',
}}
>
Failed to load
</div>
)}
{/* Actual image - only loads when in view */}
{inView && !error && (
<img
src={getUrl()}
alt={alt}
loading={lazy ? 'lazy' : 'eager'}
decoding="async"
onLoad={() => setLoaded(true)}
onError={() => setError(true)}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
opacity: loaded ? 1 : 0,
transition: 'opacity 0.3s ease',
}}
/>
)}
<style jsx global>{`
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
`}</style>
</div>
);
}
+6
View File
@@ -20,6 +20,8 @@ export async function initSchema() {
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
filename VARCHAR(255) NOT NULL, filename VARCHAR(255) NOT NULL,
data TEXT NOT NULL, data TEXT NOT NULL,
thumbnail TEXT,
medium TEXT,
category VARCHAR(100) DEFAULT 'other', category VARCHAR(100) DEFAULT 'other',
room_id INTEGER REFERENCES rooms(id) ON DELETE SET NULL, room_id INTEGER REFERENCES rooms(id) ON DELETE SET NULL,
location_target VARCHAR(100) DEFAULT 'gallery', location_target VARCHAR(100) DEFAULT 'gallery',
@@ -27,6 +29,10 @@ export async function initSchema() {
); );
`); `);
// Add columns if they don't exist (for existing databases)
await db.query(`ALTER TABLE images ADD COLUMN IF NOT EXISTS medium TEXT`);
await db.query(`ALTER TABLE images ADD COLUMN IF NOT EXISTS thumbnail TEXT`);
await db.query(` await db.query(`
CREATE TABLE IF NOT EXISTS reservations ( CREATE TABLE IF NOT EXISTS reservations (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,