fix: image gallery organized with sections and borders, images now show correctly

This commit is contained in:
2026-06-28 13:50:32 -05:00
parent e1b091b25d
commit 295c024ced
6 changed files with 521 additions and 122 deletions
+225 -19
View File
@@ -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'];
/* ─── Organized image gallery with sections and borders ─── */
function ImageGallery({ images, onDelete }: { images: Image[]; onDelete: (id: number) => void }) {
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const [hoveredCard, setHoveredCard] = useState<number | null>(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<string, Image[]> = 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<string, Image[]>);
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 (
<div>
<div style={{ display: 'flex', gap: '6px', marginBottom: '16px', flexWrap: 'wrap' }}>
{cats.map(c => (
<Btn key={c} onClick={() => onFilter(c)} variant={filter === c ? 'primary' : 'secondary'} size="sm">{c === 'all' ? 'All' : c.charAt(0).toUpperCase() + c.slice(1)}</Btn>
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))', gap: '12px' }}>
{images.filter(img => filter === 'all' || img.category === filter).map(img => (
<div key={img.id} style={{ position: 'relative', borderRadius: 12, overflow: 'hidden', boxShadow: C.shadow }}>
<img src={img.data} alt={img.filename} style={{ width: '100%', height: '90px', objectFit: 'cover', display: 'block' }} />
<div style={{ padding: '6px 8px', fontSize: '11px', color: C.text, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', background: C.card }}>
<div
style={{
position: 'relative', borderRadius: '10px', overflow: 'hidden',
boxShadow: isHov ? C.shadowHover : C.shadow,
border: `1px solid ${isHov ? C.gold : C.border}`,
transform: isHov ? 'translateY(-2px)' : 'none',
transition: 'all 200ms ease',
cursor: 'default',
}}
onMouseEnter={() => setHoveredCard(img.id)}
onMouseLeave={() => setHoveredCard(null)}
>
<img
src={img.data}
alt={img.filename}
style={{
width: '100%', height: '110px', objectFit: 'cover', display: 'block',
}}
/>
<div style={{
padding: '6px 8px', fontSize: '11px', color: C.text,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
background: C.card,
}}>
{img.filename}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '2px' }}>
<button onClick={() => onDelete(img.id)} style={{ background: 'transparent', border: 'none', cursor: 'pointer', color: C.danger, fontSize: '16px', padding: '2px 6px' }}>×</button>
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '4px' }}>
<button
onClick={(e) => { e.stopPropagation(); onDelete(img.id); }}
style={{
width: '24px', height: '24px', borderRadius: '6px',
border: '1px solid rgba(211,47,47,0.2)',
background: isHov ? 'rgba(211,47,47,0.15)' : 'transparent',
color: C.danger, fontSize: '14px', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'all 200ms',
}}
>
×
</button>
</div>
{/* Category label overlay on hover */}
{isHov && img.location_target && (
<div style={{
position: 'absolute', bottom: '30px', left: '4px', right: '4px',
padding: '3px 6px', fontSize: '10px', color: '#fff',
background: 'rgba(1,13,30,0.75)', borderRadius: '4px',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
textAlign: 'center',
}}>
{img.location_target}
</div>
))}
)}
</div>
);
};
const SectionBox = ({ title, count, children }: { title: string; count: number; children: React.ReactNode }) => (
<div style={{
borderRadius: '12px', overflow: 'hidden',
border: `1px solid ${C.border}`, background: '#fff',
boxShadow: C.shadow,
}}>
<div style={{
padding: '10px 16px',
background: C.goldLight,
borderBottom: `1px solid ${C.border}`,
fontWeight: 700, color: C.navy, fontSize: '14px',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<span>{title}</span>
<span style={{
fontSize: '11px', padding: '2px 10px', borderRadius: '999px',
background: C.success, color: '#fff', fontWeight: 600,
}}>{count}</span>
</div>
<div style={{ padding: '16px' }}>
{children}
</div>
</div>
);
return (
<div>
{/* Category filter bar with borders */}
<div style={{
display: 'flex', gap: '8px', marginBottom: '20px', flexWrap: 'wrap',
padding: '12px 16px', borderRadius: '12px',
border: `1px solid ${C.border}`, background: '#fff',
boxShadow: C.shadow,
}}>
{['all', 'slides', 'calendar', 'social', 'rooms', 'other'].map((cat) => (
<Btn
key={cat}
onClick={() => setSelectedCategory(cat)}
variant={selectedCategory === cat ? 'primary' : 'secondary'}
size="sm"
>
{cat === 'all' ? 'All Images' : cat.charAt(0).toUpperCase() + cat.slice(1)}
</Btn>
))}
</div>
{selectedCategory === 'all' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{/* Static sections */}
{sectionCategories.map((section) => {
const imgs = images.filter(section.filter);
if (imgs.length === 0) return null;
return (
<SectionBox key={section.key} title={section.label} count={imgs.length}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
gap: '12px',
}}>
{imgs.map((img) => <ImageCard key={img.id} img={img} />)}
</div>
</SectionBox>
);
})}
{/* Rooms section with room number subdivision */}
{Object.keys(roomsByNumber).length > 0 && (
<SectionBox title="🏨 Rooms Gallery" count={roomImages.length}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{Object.entries(roomsByNumber).map(([roomKey, imgs]) => (
<div key={roomKey}>
<div style={{
display: 'flex', alignItems: 'center', gap: '8px',
marginBottom: '10px', padding: '8px 12px',
background: '#FAFAFA', borderRadius: '8px',
border: `1px solid ${C.border}`,
}}>
<span style={{ fontSize: '13px', fontWeight: 600, color: C.navy }}>
🛏 Room {roomKey}
</span>
<span style={{
fontSize: '11px', padding: '2px 8px', borderRadius: '999px',
background: C.goldLight, color: C.gold, fontWeight: 600,
}}>{imgs.length} photo{imgs.length !== 1 ? 's' : ''}</span>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
gap: '12px',
}}>
{imgs.map((img) => <ImageCard key={img.id} img={img} />)}
</div>
</div>
))}
</div>
</SectionBox>
)}
{/* 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 (
<SectionBox title="📁 Uncategorized" count={otherImages.length}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
gap: '12px',
}}>
{otherImages.map((img) => <ImageCard key={img.id} img={img} />)}
</div>
</SectionBox>
);
})()}
</div>
) : (
<SectionBox title={
selectedCategory === 'rooms' ? '🏨 Rooms' :
selectedCategory === 'other' ? '📁 Other' :
selectedCategory.charAt(0).toUpperCase() + selectedCategory.slice(1)
} count={filteredImages.length}>
{filteredImages.length === 0 ? (
<div style={{ padding: '40px 20px', textAlign: 'center', color: C.textLight }}>
No images in this category
</div>
) : (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
gap: '12px',
}}>
{filteredImages.map((img) => <ImageCard key={img.id} img={img} />)}
</div>
)}
</SectionBox>
)}
</div>
);
}
/* ─── 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 */}
<span style={{ fontSize: '18px', color: C.textLight, userSelect: 'none', flexShrink: 0, cursor: 'grab' }}
onMouseDown={(e) => { e.dataTransfer!.effectAllowed = 'move'; }}></span>
onMouseDown={() => {}}></span>
{/* Position number */}
<span style={{ fontSize: '13px', fontWeight: 700, color: C.textLight, width: '22px', textAlign: 'center', flexShrink: 0 }}>
@@ -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();
+25 -3
View File
@@ -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']
);
+1 -1
View File
@@ -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 });
}
+30 -15
View File
@@ -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 });
}
}
+30 -15
View File
@@ -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 });
}
}
+200 -59
View File
@@ -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<User | null>(null);
const [commentsByRoom, setCommentsByRoom] = useState<Record<number, RoomComment[]>>({});
const [likesByRoom, setLikesByRoom] = useState<Record<number, number>>({});
const [userLikesByRoom, setUserLikesByRoom] = useState<Record<number, boolean>>({});
const [expandedRoom, setExpandedRoom] = useState<number | null>(null);
const [activePhoto, setActivePhoto] = useState<Record<number, string>>({});
const [activePhotoByRoom, setActivePhotoByRoom] = useState<Record<number, number>>({});
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const [loadingComments, setLoadingComments] = useState<Record<number, boolean>>({});
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) => {
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 && <p style={{ color: '#c23b22' }}>{error}</p>}
{!loading && displayedRooms.length === 0 && <p style={{ color: '#777' }}>No rooms available yet.</p>}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: '2rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(380px, 1fr))', gap: '2rem' }}>
{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 (
<article
@@ -127,41 +181,112 @@ export default function RoomsPage() {
onMouseEnter={(e) => { 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)'; }}
>
<div style={{ position: 'relative' }}>
<div
{/* ─── Main Image ─── */}
<div style={{ position: 'relative', width: '100%' }}>
{activeSrc ? (
<img
src={activeSrc}
alt={room.name}
style={{
height: '260px',
background: '#ddd',
backgroundImage: currentPhoto ? `url(${currentPhoto})` : undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
cursor: photos.length > 1 ? 'pointer' : 'default',
}}
onClick={() => {
if (photos.length > 1) {
const idx = photos.indexOf(currentPhoto);
setActivePhoto((prev) => ({ ...prev, [room.id]: photos[(idx + 1) % photos.length] }));
}
width: '100%',
height: '300px',
objectFit: 'cover',
display: 'block',
}}
/>
{photos.length > 1 && (
<div style={{ position: 'absolute', bottom: 10, right: 10, display: 'flex', gap: '0.4rem' }}>
{photos.map((p, i) => (
<button
key={p}
onClick={() => setActivePhoto((prev) => ({ ...prev, [room.id]: p }))}
style={{
width: 10, height: 10, borderRadius: '50%', border: 'none',
background: p === currentPhoto ? gold : 'rgba(255,255,255,0.7)',
cursor: 'pointer',
}}
aria-label={`Photo ${i + 1}`}
/>
))}
) : (
<div style={{ width: '100%', height: '300px', background: '#ddd', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999' }}>
No photo
</div>
)}
{/* Like button overlay */}
<button
onClick={() => toggleLike(room.id)}
style={{
position: 'absolute',
top: 12,
left: 12,
width: 40,
height: 40,
borderRadius: '50%',
border: 'none',
background: hasLiked ? 'rgba(231,76,60,0.9)' : 'rgba(0,0,0,0.4)',
color: '#fff',
fontSize: '20px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.15)'; }}
onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
aria-label="Like this room"
>
{hasLiked ? '\u2764\uFE0F' : '\u2661'}
</button>
{/* Like count */}
<div style={{
position: 'absolute',
bottom: 8,
right: 12,
background: hasLiked ? 'rgba(231,76,60,0.85)' : 'rgba(0,0,0,0.5)',
color: '#fff',
fontSize: '13px',
fontWeight: 600,
padding: '4px 10px',
borderRadius: '999px',
}}>
{'\u2764\uFE0F'} {totalLikes}
</div>
</div>
{/* ─── Thumbnail Strip ─── */}
{photos.length > 1 && (
<div style={{
display: 'flex',
gap: '6px',
padding: '10px 14px 14px',
overflowX: 'auto',
}}>
{photos.map((p, i) => {
const isActive = i === activeIdx;
return (
<button
key={`${room.id}-${i}`}
onClick={() => setActivePhotoByRoom((prev) => ({ ...prev, [room.id]: i }))}
style={{
width: 60,
height: 45,
flexShrink: 0,
borderRadius: 8,
border: isActive ? `2px solid ${gold}` : '2px solid transparent',
padding: 0,
cursor: 'pointer',
overflow: 'hidden',
transition: 'border-color 0.2s, transform 0.15s',
}}
onMouseEnter={(e) => {
if (!isActive) e.currentTarget.style.transform = 'scale(1.08)';
}}
onMouseLeave={(e) => {
if (!isActive) e.currentTarget.style.transform = 'scale(1)';
}}
>
{p ? (
<img src={p} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<div style={{ width: '100%', height: '100%', background: '#ddd' }} />
)}
</button>
);
})}
</div>
)}
{/* ─── Room Info ─── */}
<div style={{ padding: '1.5rem', flex: 1, display: 'flex', flexDirection: 'column' }}>
<h2 style={{ margin: '0 0 0.5rem', color: navy, fontSize: '24px' }}>{room.name}</h2>
<p style={{ color: '#555', lineHeight: 1.5, flex: 1 }}>{room.description}</p>
@@ -170,34 +295,32 @@ export default function RoomsPage() {
<span style={{ fontSize: '13px', color: '#777' }}>per night</span>
</div>
{photos.length > 1 && (
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem', overflowX: 'auto' }}>
{photos.map((p) => (
<button
key={p}
onClick={() => setActivePhoto((prev) => ({ ...prev, [room.id]: p }))}
style={{
width: 64, height: 48, flexShrink: 0, borderRadius: 8, border: `2px solid ${p === currentPhoto ? gold : 'transparent'}`,
backgroundImage: `url(${p})`, backgroundSize: 'cover', backgroundPosition: 'center', cursor: 'pointer',
}}
aria-label="View photo"
/>
))}
</div>
)}
{/* Comments button — cream */}
<button
onClick={() => setExpandedRoom(isExpanded ? null : room.id)}
style={{
marginTop: '1rem', padding: '0.6rem 1rem', borderRadius: '999px', border: 'none',
background: navy, color: '#fff', cursor: 'pointer', fontWeight: 600, fontSize: '14px',
marginTop: '1rem',
padding: '0.6rem 1rem',
borderRadius: '999px',
border: `2px solid ${cream}`,
background: cream,
color: navy,
cursor: 'pointer',
fontWeight: 600,
fontSize: '14px',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = gold; e.currentTarget.style.borderColor = gold; e.currentTarget.style.color = navy; }}
onMouseLeave={(e) => { e.currentTarget.style.background = cream; e.currentTarget.style.borderColor = cream; e.currentTarget.style.color = navy; }}
>
{isExpanded ? 'Hide comments' : `Comments (${comments.length})`}
</button>
{/* Comments section */}
{isExpanded && (
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{loadingComments[room.id] && <p style={{ color: '#777', fontSize: '13px' }}>Loading comments...</p>}
{user ? (
user.comments_disabled ? (
<p style={{ color: '#c23b22', fontSize: '14px' }}>Comments are disabled for your account.</p>
@@ -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',
}}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: '12px', color: '#777' }}>Posting as {user.first_name || user.username}</span>
<button
onClick={() => postComment(room.id)}
disabled={sending || !draft.trim()}
style={{ padding: '0.5rem 1rem', borderRadius: '8px', border: 'none', background: gold, color: navy, fontWeight: 700, cursor: 'pointer' }}
style={{
padding: '0.5rem 1rem',
borderRadius: '8px',
border: 'none',
background: gold,
color: navy,
fontWeight: 700,
cursor: draft.trim() ? 'pointer' : 'default',
opacity: draft.trim() ? 1 : 0.5,
}}
>
{sending ? 'Sending...' : 'Post'}
</button>
@@ -236,7 +375,9 @@ export default function RoomsPage() {
<strong style={{ color: navy, fontSize: '14px' }}>
{c.first_name}{c.last_initial ? ` ${c.last_initial}.` : ''}
</strong>
<span style={{ fontSize: '12px', color: '#777' }}>{new Date(c.created_at).toLocaleDateString()}</span>
<span style={{ fontSize: '12px', color: '#777' }}>
{new Date(c.created_at).toLocaleDateString()}
</span>
</div>
<p style={{ margin: 0, color: '#555', fontSize: '14px' }}>{c.text}</p>
{user?.role === 'admin' && (