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
+206 -65
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) => {
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 && <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
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] }));
}
}}
/>
{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}`}
/>
))}
{/* ─── Main Image ─── */}
<div style={{ position: 'relative', width: '100%' }}>
{activeSrc ? (
<img
src={activeSrc}
alt={room.name}
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' }}>
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' && (