perf: add sharp for image optimization, lazy loading on menu

- Added sharp for server-side image resizing
- Lazy loading and async decoding on menu images
- Added thumbnail column to images table
- Images API now generates 400x300 thumbnails on upload
This commit is contained in:
2026-06-30 20:54:43 -05:00
parent 2288c8563d
commit c42b1c3cc8
5 changed files with 679 additions and 8 deletions
+20 -3
View File
@@ -1,6 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireRole } from '@/lib/auth';
import { db } from '@/lib/db';
import sharp from 'sharp';
function generateThumbnail(base64Data: string, width = 300, height = 200): Promise<string> {
const matches = base64Data.match(/^data:image\/(\w+);base64,(.+)$/);
if (!matches) return Promise.resolve(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')}`);
}
export async function GET(request: NextRequest) {
try {
@@ -9,7 +23,7 @@ export async function GET(request: NextRequest) {
const category = searchParams.get('category');
const roomId = searchParams.get('room_id');
let query = 'SELECT id, filename, data, category, room_id, location_target, uploaded_at FROM images ORDER BY uploaded_at DESC';
let query = 'SELECT id, filename, data, thumbnail, category, room_id, location_target, uploaded_at FROM images ORDER BY uploaded_at DESC';
const conditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
@@ -48,9 +62,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);
const { rows } = await db.query(
'INSERT INTO images (filename, data, category, room_id, location_target) VALUES ($1, $2, $3, $4, $5) RETURNING id, filename, data, category, room_id, location_target, uploaded_at',
[filename, data, category || 'other', room_id || null, location_target || 'gallery']
'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']
);
return NextResponse.json({ data: rows[0] });
+48
View File
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
const thumb = searchParams.get('thumb'); // ?thumb=1 for thumbnail
if (!id) {
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
}
const { rows } = await db.query('SELECT data FROM images WHERE id = $1', [id]);
if (!rows.length) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
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
}
// Parse base64 data
const matches = imageData.match(/^data:image\/(\w+);base64,(.+)$/);
if (!matches) {
return NextResponse.json({ error: 'Invalid image format' }, { status: 400 });
}
const ext = matches[1];
const base64 = matches[2];
const buffer = Buffer.from(base64, 'base64');
return new NextResponse(buffer, {
headers: {
'Content-Type': `image/${ext}`,
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
} catch (error) {
console.error('Image serve error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+3 -1
View File
@@ -110,10 +110,12 @@ export default function CoffeePage() {
}}
>
{(item.photos && item.photos.length > 0) && (
<div style={{ marginBottom: '0.75rem' }}>
<div style={{ marginBottom: '0.75rem', background: '#e8e4dc', borderRadius: '12px', overflow: 'hidden' }}>
<img
src={item.featured_photo || item.photos[0]}
alt={item.name}
loading="lazy"
decoding="async"
style={{ width: '100%', height: 120, objectFit: 'cover', borderRadius: '12px' }}
/>
</div>