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 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,(.+)$/);
if (!matches) return Promise.resolve(base64Data);
if (!matches) {
return { thumbnail: base64Data, medium: base64Data, data: base64Data };
}
const ext = matches[1];
const buffer = Buffer.from(matches[2], 'base64');
return sharp(buffer)
.resize(width, height, { fit: 'cover' })
.toBuffer()
.then(resized => `data:image/${ext};base64,${resized.toString('base64')}`);
// Convert to WebP for better compression
const toBase64 = (buf: Buffer) => `data:image/webp;base64,${buf.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) {
@@ -62,12 +91,12 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing filename or data' }, { status: 400 });
}
// Generate thumbnail for faster loading
const thumbnail = await generateThumbnail(data, 400, 300);
// Generate multiple sizes for responsive images (thumbnail, medium, full)
const sizes = await generateImageSizes(data);
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',
[filename, data, thumbnail, category || 'other', room_id || null, location_target || 'gallery']
'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, sizes.data, sizes.thumbnail, sizes.medium, category || 'other', room_id || null, location_target || 'gallery']
);
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 id = searchParams.get('id');
const thumb = searchParams.get('thumb'); // ?thumb=1 for thumbnail
const medium = searchParams.get('medium'); // ?medium=1 for medium size
if (!id) {
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) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
@@ -19,10 +26,12 @@ export async function GET(request: NextRequest) {
const imageData = rows[0].data;
// If thumbnail requested and we have thumbnail data stored
if (thumb) {
// For now, return the full image (thumbnail generation happens on upload)
// Future: store thumbnail separately in thumbnail column
if (!imageData) {
// Fallback to full image if variant doesn't exist
const { rows: fallback } = await db.query('SELECT data FROM images WHERE id = $1', [id]);
if (!fallback.length || !fallback[0].data) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
}
// Parse base64 data
+14 -10
View File
@@ -4,6 +4,7 @@ import { useEffect, useState, useRef } from 'react';
import { formatDisplayDate } from '@/lib/date';
import RoomCalendar from '@/components/RoomCalendar';
import LikeButton from '@/components/LikeButton';
import OptimizedImage from '@/components/OptimizedImage';
interface Room {
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)'; }}
>
{/* ─── Main Image ─── */}
<div style={{ position: 'relative', width: '100%' }}>
<div style={{ position: 'relative', width: '100%', height: '300px' }}>
{activeSrc ? (
<img
src={activeSrc}
<OptimizedImage
id={parseInt(activeSrc.split('id=')[1])}
alt={room.name}
style={{
width: '100%',
height: '300px',
objectFit: 'cover',
display: 'block',
}}
size="medium"
lazy={true}
style={{ width: '100%', height: '300px' }}
/>
) : (
<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 ? (
<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' }} />
)}
+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,
filename VARCHAR(255) NOT NULL,
data TEXT NOT NULL,
thumbnail TEXT,
medium TEXT,
category VARCHAR(100) DEFAULT 'other',
room_id INTEGER REFERENCES rooms(id) ON DELETE SET NULL,
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(`
CREATE TABLE IF NOT EXISTS reservations (
id SERIAL PRIMARY KEY,