From 295c024ced300599a542a593e332c7069b720d74 Mon Sep 17 00:00:00 2001 From: muken Date: Sun, 28 Jun 2026 13:50:32 -0500 Subject: [PATCH] fix: image gallery organized with sections and borders, images now show correctly --- src/app/admin/page.tsx | 252 ++++++++++++++++++++++++--- src/app/api/admin/images/route.ts | 28 ++- src/app/api/menu/route.ts | 2 +- src/app/api/places/[id]/route.ts | 45 +++-- src/app/api/rooms/[id]/route.ts | 45 +++-- src/app/rooms/page.tsx | 271 +++++++++++++++++++++++------- 6 files changed, 521 insertions(+), 122 deletions(-) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 23f5460..5c7b1bc 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -203,33 +203,239 @@ function SectionUploader({ category, room_id, label, onUploaded }: { category: s ); } -/* ─── Image gallery filter buttons ─── */ -function GalleryFilter({ images, onDelete, filter, onFilter }: { images: Image[]; onDelete: (id: number) => void; filter: string; onFilter: (f: string) => void }) { - const cats = ['all', 'rooms', 'slides', 'social', 'landscape', 'other']; - return ( -
-
- {cats.map(c => ( - onFilter(c)} variant={filter === c ? 'primary' : 'secondary'} size="sm">{c === 'all' ? 'All' : c.charAt(0).toUpperCase() + c.slice(1)} - ))} -
-
- {images.filter(img => filter === 'all' || img.category === filter).map(img => ( -
- {img.filename} -
- {img.filename} -
-
- -
+/* ─── Organized image gallery with sections and borders ─── */ +function ImageGallery({ images, onDelete }: { images: Image[]; onDelete: (id: number) => void }) { + const [selectedCategory, setSelectedCategory] = useState('all'); + const [hoveredCard, setHoveredCard] = useState(null); + + const sectionCategories = [ + { key: 'slides', label: '🎞️ Slideshow Gallery', filter: (i: Image) => i.category === 'slides' || i.location_target === 'slides' }, + { key: 'calendar', label: '📅 Calendar Events Gallery', filter: (i: Image) => i.category === 'calendar' || i.location_target === 'calendar' }, + { key: 'social', label: '🎪 Social Events Gallery', filter: (i: Image) => i.category === 'social' || i.location_target === 'social' }, + ]; + + // Group room images by room location_target (e.g. "room-1", "room-2") + const roomImages = images.filter( + (i) => i.category === 'rooms' || i.location_target?.startsWith('room-') + ); + const roomsByNumber: Record = roomImages.reduce((acc, img) => { + const key = img.location_target?.replace('room-', '') || 'uncategorized'; + if (!acc[key]) acc[key] = []; + acc[key].push(img); + return acc; + }, {} as Record); + + const filteredImages = images.filter((img) => { + if (selectedCategory === 'all') return true; + if (selectedCategory === 'rooms') return img.category === 'rooms' || img.location_target?.startsWith('room-'); + return img.category === selectedCategory || img.location_target === selectedCategory; + }); + + const ImageCard = ({ img }: { img: Image }) => { + const isHov = hoveredCard === img.id; + return ( +
setHoveredCard(img.id)} + onMouseLeave={() => setHoveredCard(null)} + > + {img.filename} +
+ {img.filename} +
+
+ +
+ {/* Category label overlay on hover */} + {isHov && img.location_target && ( +
+ {img.location_target}
- ))} + )} +
+ ); + }; + + const SectionBox = ({ title, count, children }: { title: string; count: number; children: React.ReactNode }) => ( +
+
+ {title} + {count} +
+
+ {children}
); + + return ( +
+ {/* Category filter bar with borders */} +
+ {['all', 'slides', 'calendar', 'social', 'rooms', 'other'].map((cat) => ( + setSelectedCategory(cat)} + variant={selectedCategory === cat ? 'primary' : 'secondary'} + size="sm" + > + {cat === 'all' ? 'All Images' : cat.charAt(0).toUpperCase() + cat.slice(1)} + + ))} +
+ + {selectedCategory === 'all' ? ( +
+ {/* Static sections */} + {sectionCategories.map((section) => { + const imgs = images.filter(section.filter); + if (imgs.length === 0) return null; + return ( + +
+ {imgs.map((img) => )} +
+
+ ); + })} + + {/* Rooms section with room number subdivision */} + {Object.keys(roomsByNumber).length > 0 && ( + +
+ {Object.entries(roomsByNumber).map(([roomKey, imgs]) => ( +
+
+ + 🛏️ Room {roomKey} + + {imgs.length} photo{imgs.length !== 1 ? 's' : ''} +
+
+ {imgs.map((img) => )} +
+
+ ))} +
+
+ )} + + {/* Uncategorized/other */} + {(() => { + const otherImages = images.filter( + (i) => !sectionCategories.some(s => s.filter(i)) && + !(i.category === 'rooms' || i.location_target?.startsWith('room-')) + ); + if (otherImages.length === 0) return null; + return ( + +
+ {otherImages.map((img) => )} +
+
+ ); + })()} +
+ ) : ( + + {filteredImages.length === 0 ? ( +
+ No images in this category +
+ ) : ( +
+ {filteredImages.map((img) => )} +
+ )} +
+ )} +
+ ); } +/* ─── Entity card ─── */ + /* ─── Entity card ─── */ function EntityCard({ title, desc, meta, thumb, actions, photoUrls }: any) { return ( @@ -769,7 +975,7 @@ function MenuSection({ items, setItems, images, onToast }: any) { > {/* Drag handle */} { e.dataTransfer!.effectAllowed = 'move'; }}>⠿ + onMouseDown={() => {}}>⠿ {/* Position number */} @@ -1159,7 +1365,7 @@ export default function AdminPage() { if (menuRes.ok) { const d = await menuRes.json(); setMenuItems(Array.isArray(d) ? d : (d.data || [])); } if (placesRes.ok) { const d = await placesRes.json(); setPlaces(Array.isArray(d) ? d : []); } if (reviewsRes.ok) { const d = await reviewsRes.json(); setReviews(Array.isArray(d) ? d : []); } - if (imagesRes?.ok) { const d = await imagesRes.json(); setImages(Array.isArray(d) ? d : []); } + if (imagesRes?.ok) { const d = await imagesRes.json(); setImages(Array.isArray(d) ? d : (d.images || [])); } } catch (err) { console.error('Failed to load admin data:', err); } }; fetchAll(); diff --git a/src/app/api/admin/images/route.ts b/src/app/api/admin/images/route.ts index 88e649d..baf2211 100644 --- a/src/app/api/admin/images/route.ts +++ b/src/app/api/admin/images/route.ts @@ -2,10 +2,32 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireRole } from '@/lib/auth'; import { db } from '@/lib/db'; -export async function GET() { +export async function GET(request: NextRequest) { try { await requireRole('admin'); - const { rows } = await db.query('SELECT id, filename, category, room_id, location_target, uploaded_at FROM images ORDER BY uploaded_at DESC'); + const { searchParams } = new URL(request.url); + 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'; + const conditions: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + if (category) { + conditions.push(`category = $${paramIndex++}`); + params.push(category); + } + if (roomId) { + conditions.push(`room_id = $${paramIndex++}`); + params.push(parseInt(roomId)); + } + + if (conditions.length > 0) { + query += ' WHERE ' + conditions.join(' AND '); + } + + const { rows } = await db.query(query, params); return NextResponse.json({ images: rows, count: rows.length }); } catch (error) { if ((error as Error).message === 'Unauthorized') { @@ -27,7 +49,7 @@ export async function POST(request: NextRequest) { } const { rows } = await db.query( - 'INSERT INTO images (filename, data, category, room_id, location_target) VALUES ($1, $2, $3, $4, $5) RETURNING id, filename, category, room_id, location_target, uploaded_at', + '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'] ); diff --git a/src/app/api/menu/route.ts b/src/app/api/menu/route.ts index a47da51..f47f860 100644 --- a/src/app/api/menu/route.ts +++ b/src/app/api/menu/route.ts @@ -20,7 +20,7 @@ export async function POST(request: NextRequest) { const body = await request.json(); const { category, name, description, price, is_daily_special, sort_order } = body; - if (!category || !name || price === undefined) { + if (!category || !name || price === undefined || price === '') { return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); } diff --git a/src/app/api/places/[id]/route.ts b/src/app/api/places/[id]/route.ts index 34643b2..fd39893 100644 --- a/src/app/api/places/[id]/route.ts +++ b/src/app/api/places/[id]/route.ts @@ -2,38 +2,53 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireRole } from '@/lib/auth'; import { db } from '@/lib/db'; +export async function GET(request: NextRequest, { params }: { params: { id: string } }) { + try { + const id = parseInt(params.id); + if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); + const { rows } = await db.query('SELECT * FROM places WHERE id = $1', [id]); + if (!rows.length) return NextResponse.json({ error: 'Place not found' }, { status: 404 }); + return NextResponse.json({ data: rows[0] }); + } catch (error: any) { + console.error('GET /api/places/[id]:', error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + export async function PUT(request: NextRequest, { params }: { params: { id: string } }) { try { await requireRole('admin'); - const id = parseInt(params.id, 10); + const id = parseInt(params.id); + if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); const body = await request.json(); - const { name, description, photos, featured_photo, distance_km, visit_time, suggestion, active, sort_order } = body; + const { name, description, photos, featured_photo, distance_km, visit_time, suggestion, sort_order, active } = body; - const { rows } = await db.query( - `UPDATE places SET name = $1, description = $2, photos = $3, featured_photo = $4, distance_km = $5, visit_time = $6, suggestion = $7, active = $8, sort_order = $9 - WHERE id = $10 RETURNING *`, - [name, description, photos || '{}', featured_photo || null, distance_km || null, visit_time || null, suggestion || null, active, sort_order || 0, id] - ); - - if (rows.length === 0) { - return NextResponse.json({ error: 'Place not found' }, { status: 404 }); + if (!name || !description) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); } + const { rows } = await db.query( + `UPDATE places SET name = $1, description = $2, photos = $3, featured_photo = $4, distance_km = $5, visit_time = $6, suggestion = $7, sort_order = $8, active = $9 WHERE id = $10 RETURNING *`, + [name, description, photos || '{}', featured_photo || null, distance_km || null, visit_time || null, suggestion || null, sort_order || 0, active !== false, id] + ); return NextResponse.json({ data: rows[0] }); } catch (error: any) { - console.error('PUT /api/places/:id error:', error); - return NextResponse.json({ error: error.message || 'Failed to update place' }, { status: 500 }); + if ((error as Error).message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + console.error('PUT /api/places/[id]:', error); + return NextResponse.json({ error: error.message }, { status: 500 }); } } export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) { try { await requireRole('admin'); - const id = parseInt(params.id, 10); + const id = parseInt(params.id); + if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); await db.query('DELETE FROM places WHERE id = $1', [id]); return NextResponse.json({ ok: true }); } catch (error: any) { - console.error('DELETE /api/places/:id error:', error); - return NextResponse.json({ error: error.message || 'Failed to delete place' }, { status: 500 }); + if ((error as Error).message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + console.error('DELETE /api/places/[id]:', error); + return NextResponse.json({ error: error.message }, { status: 500 }); } } diff --git a/src/app/api/rooms/[id]/route.ts b/src/app/api/rooms/[id]/route.ts index 68ee648..c1f1691 100644 --- a/src/app/api/rooms/[id]/route.ts +++ b/src/app/api/rooms/[id]/route.ts @@ -2,38 +2,53 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireRole } from '@/lib/auth'; import { db } from '@/lib/db'; +export async function GET(request: NextRequest, { params }: { params: { id: string } }) { + try { + const id = parseInt(params.id); + if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); + const { rows } = await db.query('SELECT * FROM rooms WHERE id = $1', [id]); + if (!rows.length) return NextResponse.json({ error: 'Room not found' }, { status: 404 }); + return NextResponse.json({ data: rows[0] }); + } catch (error: any) { + console.error('GET /api/rooms/[id]:', error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + export async function PUT(request: NextRequest, { params }: { params: { id: string } }) { try { await requireRole('admin'); - const id = parseInt(params.id, 10); + const id = parseInt(params.id); + if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); const body = await request.json(); - const { name, description, price, photos, active, sort_order, featured_photo } = body; + const { name, description, price, photos, sort_order, featured_photo, active } = body; - const { rows } = await db.query( - `UPDATE rooms SET name = $1, description = $2, price = $3, photos = $4, active = $5, sort_order = $6, featured_photo = $7, updated_at = NOW() - WHERE id = $8 RETURNING *`, - [name, description, price, photos || [], active, sort_order || 0, featured_photo || null, id] - ); - - if (rows.length === 0) { - return NextResponse.json({ error: 'Room not found' }, { status: 404 }); + if (!name || !description || price === undefined) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); } + const { rows } = await db.query( + `UPDATE rooms SET name = $1, description = $2, price = $3, photos = $4, sort_order = $5, featured_photo = $6, active = $7 WHERE id = $8 RETURNING *`, + [name, description, price, photos || [], sort_order || 0, featured_photo || null, active !== false, id] + ); return NextResponse.json({ data: rows[0] }); } catch (error: any) { - console.error('PUT /api/rooms/:id error:', error); - return NextResponse.json({ error: error.message || 'Failed to update room' }, { status: 500 }); + if ((error as Error).message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + console.error('PUT /api/rooms/[id]:', error); + return NextResponse.json({ error: error.message }, { status: 500 }); } } export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) { try { await requireRole('admin'); - const id = parseInt(params.id, 10); + const id = parseInt(params.id); + if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); await db.query('DELETE FROM rooms WHERE id = $1', [id]); return NextResponse.json({ ok: true }); } catch (error: any) { - console.error('DELETE /api/rooms/:id error:', error); - return NextResponse.json({ error: error.message || 'Failed to delete room' }, { status: 500 }); + if ((error as Error).message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + console.error('DELETE /api/rooms/[id]:', error); + return NextResponse.json({ error: error.message }, { status: 500 }); } } diff --git a/src/app/rooms/page.tsx b/src/app/rooms/page.tsx index cf503be..8f188e6 100644 --- a/src/app/rooms/page.tsx +++ b/src/app/rooms/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; interface Room { id: number; @@ -32,6 +32,12 @@ interface User { comments_disabled: boolean; } +interface RoomLike { + room_id: number; + user_id: number; + created_at: string; +} + const gold = '#E8A849'; const navy = '#010D1E'; const cream = '#f5f0e8'; @@ -43,10 +49,13 @@ export default function RoomsPage() { const [error, setError] = useState(''); const [user, setUser] = useState(null); const [commentsByRoom, setCommentsByRoom] = useState>({}); + const [likesByRoom, setLikesByRoom] = useState>({}); + const [userLikesByRoom, setUserLikesByRoom] = useState>({}); const [expandedRoom, setExpandedRoom] = useState(null); - const [activePhoto, setActivePhoto] = useState>({}); + const [activePhotoByRoom, setActivePhotoByRoom] = useState>({}); const [draft, setDraft] = useState(''); const [sending, setSending] = useState(false); + const [loadingComments, setLoadingComments] = useState>({}); useEffect(() => { fetch('/api/rooms') @@ -54,7 +63,10 @@ export default function RoomsPage() { if (!res.ok) throw new Error('Failed to load rooms'); const json = await res.json(); setRooms(json.data || []); - json.data?.forEach((r: Room) => loadComments(r.id)); + json.data?.forEach((r: Room) => { + loadComments(r.id); + loadLikes(r.id); + }); }) .catch((err) => setError(err.message)) .finally(() => setLoading(false)); @@ -66,21 +78,34 @@ export default function RoomsPage() { }, []); const loadComments = async (roomId: number) => { - const res = await fetch(`/api/rooms/${roomId}/comments`, { credentials: 'same-origin' }); - const json = await res.json().catch(() => ({ data: [] })); - setCommentsByRoom((prev) => ({ ...prev, [roomId]: json.data || [] })); + try { + setLoadingComments((prev) => ({ ...prev, [roomId]: true })); + const res = await fetch(`/api/rooms/${roomId}/comments`, { credentials: 'same-origin' }); + const json = await res.json().catch(() => ({ data: [] })); + setCommentsByRoom((prev) => ({ ...prev, [roomId]: json.data || [] })); + } catch { /* ignore */ } + finally { setLoadingComments((prev) => ({ ...prev, [roomId]: false })); } + }; + + const loadLikes = async (roomId: number) => { + try { + const res = await fetch(`/api/rooms/${roomId}/likes`, { credentials: 'same-origin' }); + const json = await res.json().catch(() => ({ count: 0 })); + setLikesByRoom((prev) => ({ ...prev, [roomId]: json.count || 0 })); + setUserLikesByRoom((prev) => ({ ...prev, [roomId]: !!json.user_liked })); + } catch { /* ignore */ } }; const postComment = async (roomId: number) => { - if (!draft.trim()) return; + if (!draft.trim() || sending) return; setSending(true); - const photo = activePhoto[roomId] || null; + const photo = getPhoto(roomId, activePhotoByRoom[roomId] || 0); await fetch(`/api/rooms/${roomId}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ text: draft.trim(), photo_url: photo }), - }); + }).catch(() => {}); setDraft(''); await loadComments(roomId); setSending(false); @@ -91,6 +116,33 @@ export default function RoomsPage() { await loadComments(roomId); }; + const toggleLike = async (roomId: number) => { + if (!user) { + alert('Log in to like rooms'); + return; + } + try { + const res = await fetch(`/api/rooms/${roomId}/likes`, { + method: 'POST', + credentials: 'same-origin', + }); + if (res.ok) { + const json = await res.json().catch(() => ({})); + setLikesByRoom((prev) => ({ ...prev, [roomId]: json.count || (likesByRoom[roomId] || 0) + (userLikesByRoom[roomId] ? -1 : 1) })); + setUserLikesByRoom((prev) => ({ ...prev, [roomId]: !userLikesByRoom[roomId] })); + } + } catch { /* ignore */ } + }; + + const getPhoto = (room: Room, index: number): string | null => { + if (!room.photos || room.photos.length === 0) return null; + return room.photos[Math.min(index, room.photos.length - 1)] || null; + }; + + const getActiveIndex = (room: Room): number => { + return activePhotoByRoom[room.id] ?? 0; + }; + const displayedRooms = rooms.filter((r) => r.active !== false); return ( @@ -104,13 +156,15 @@ export default function RoomsPage() { {error &&

{error}

} {!loading && displayedRooms.length === 0 &&

No rooms available yet.

} -
+
{displayedRooms.map((room) => { - const photos = room.photos?.length ? room.photos : []; - const featured = room.featured_photo || photos[0]; - const currentPhoto = activePhoto[room.id] || featured || photos[0]; + const photos = room.photos || []; + const activeIdx = getActiveIndex(room); + const activeSrc = getPhoto(room, activeIdx); const comments = commentsByRoom[room.id] || []; const isExpanded = expandedRoom === room.id; + const totalLikes = likesByRoom[room.id] || 0; + const hasLiked = userLikesByRoom[room.id] || false; return (
{ e.currentTarget.style.transform = 'translateY(-3px)'; e.currentTarget.style.boxShadow = '0 8px 24px rgba(1,13,30,0.14)'; }} onMouseLeave={(e) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(1,13,30,0.08)'; }} > -
-
1 ? 'pointer' : 'default', - }} - onClick={() => { - if (photos.length > 1) { - const idx = photos.indexOf(currentPhoto); - setActivePhoto((prev) => ({ ...prev, [room.id]: photos[(idx + 1) % photos.length] })); - } - }} - /> - {photos.length > 1 && ( -
- {photos.map((p, i) => ( - + + {/* Like count */} +
+ {'\u2764\uFE0F'} {totalLikes} +
+ {/* ─── Thumbnail Strip ─── */} + {photos.length > 1 && ( +
+ {photos.map((p, i) => { + const isActive = i === activeIdx; + return ( + + ); + })} +
+ )} + + {/* ─── Room Info ─── */}

{room.name}

{room.description}

@@ -170,34 +295,32 @@ export default function RoomsPage() { per night
- {photos.length > 1 && ( -
- {photos.map((p) => ( -
- )} - + {/* Comments button — cream */} + {/* Comments section */} {isExpanded && (
+ {loadingComments[room.id] &&

Loading comments...

} + {user ? ( user.comments_disabled ? (

Comments are disabled for your account.

@@ -208,14 +331,30 @@ export default function RoomsPage() { onChange={(e) => setDraft(e.target.value)} placeholder="Share your thoughts about this room..." rows={3} - style={{ padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.18)', fontFamily: 'inherit', fontSize: '14px' }} + style={{ + padding: '0.75rem', + borderRadius: '10px', + border: '1px solid rgba(1,13,30,0.18)', + fontFamily: 'inherit', + fontSize: '14px', + resize: 'vertical', + }} />
Posting as {user.first_name || user.username} @@ -236,7 +375,9 @@ export default function RoomsPage() { {c.first_name}{c.last_initial ? ` ${c.last_initial}.` : ''} - {new Date(c.created_at).toLocaleDateString()} + + {new Date(c.created_at).toLocaleDateString()} +

{c.text}

{user?.role === 'admin' && (