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