diff --git a/src/app/api/admin/images/route.ts b/src/app/api/admin/images/route.ts index 9f355a4..f4decf4 100644 --- a/src/app/api/admin/images/route.ts +++ b/src/app/api/admin/images/route.ts @@ -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 { +interface ImageSizes { + thumbnail: string; + medium: string; + data: string; +} + +async function generateImageSizes(base64Data: string): Promise { 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] }); diff --git a/src/app/api/image/route.ts b/src/app/api/image/route.ts index 480c828..68a6473 100644 --- a/src/app/api/image/route.ts +++ b/src/app/api/image/route.ts @@ -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 diff --git a/src/app/rooms/page.tsx b/src/app/rooms/page.tsx index 9faf9b7..d88845a 100644 --- a/src/app/rooms/page.tsx +++ b/src/app/rooms/page.tsx @@ -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 ─── */} -
+
{activeSrc ? ( - {room.name} ) : (
@@ -409,7 +407,13 @@ export default function RoomsPage() { }} > {p ? ( - + ) : (
)} diff --git a/src/components/OptimizedImage.tsx b/src/components/OptimizedImage.tsx new file mode 100644 index 0000000..bbf04f8 --- /dev/null +++ b/src/components/OptimizedImage.tsx @@ -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(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 ( +
+ {/* Placeholder shimmer */} + {placeholder && !loaded && !error && ( +
+ )} + + {/* Error state */} + {error && ( +
+ ⚠️ Failed to load +
+ )} + + {/* Actual image - only loads when in view */} + {inView && !error && ( + {alt} setLoaded(true)} + onError={() => setError(true)} + style={{ + width: '100%', + height: '100%', + objectFit: 'cover', + opacity: loaded ? 1 : 0, + transition: 'opacity 0.3s ease', + }} + /> + )} + + +
+ ); +} \ No newline at end of file diff --git a/src/lib/schema.ts b/src/lib/schema.ts index 8d2d828..e0f5f91 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -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,