feat: room photo gallery, featured photo, and comments moderation

This commit is contained in:
2026-06-27 19:25:00 -05:00
parent 7cc1877674
commit a11bd3ada8
10 changed files with 583 additions and 69 deletions
+215 -37
View File
@@ -8,16 +8,45 @@ interface Room {
description: string;
price: string;
photos: string[];
featured_photo: string | null;
active?: boolean;
}
interface RoomComment {
id: number;
room_id: number;
photo_url: string | null;
user_id: number;
first_name: string;
last_initial: string | null;
text: string;
created_at: string;
}
interface User {
id: number;
username: string;
role: 'admin' | 'user';
first_name: string | null;
last_name: string | null;
comments_disabled: boolean;
}
const gold = '#E8A849';
const navy = '#010D1E';
const cream = '#f5f0e8';
const warm = '#a6683c';
export default function RoomsPage() {
const [rooms, setRooms] = useState<Room[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [user, setUser] = useState<User | null>(null);
const [commentsByRoom, setCommentsByRoom] = useState<Record<number, RoomComment[]>>({});
const [expandedRoom, setExpandedRoom] = useState<number | null>(null);
const [activePhoto, setActivePhoto] = useState<Record<number, string>>({});
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
useEffect(() => {
fetch('/api/rooms')
@@ -25,61 +54,210 @@ 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));
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
fetch('/api/auth/me', { credentials: 'same-origin' })
.then((r) => r.json())
.then((j) => setUser(j.user || null))
.catch(() => {});
}, []);
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 || [] }));
};
const postComment = async (roomId: number) => {
if (!draft.trim()) return;
setSending(true);
const photo = activePhoto[roomId] || null;
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 }),
});
setDraft('');
await loadComments(roomId);
setSending(false);
};
const deleteComment = async (roomId: number, commentId: number) => {
await fetch(`/api/admin/comments/${commentId}`, { method: 'DELETE', credentials: 'same-origin' });
await loadComments(roomId);
};
const displayedRooms = rooms.filter((r) => r.active !== false);
return (
<main style={{ padding: '2rem', maxWidth: '72rem', margin: '0 auto', minHeight: '60vh' }}>
<main style={{ padding: '2rem', maxWidth: '80rem', margin: '0 auto', minHeight: '60vh' }}>
<h1 style={{ fontSize: 'clamp(32px, 5vw, 48px)', fontWeight: 400, fontStyle: 'italic', color: navy, margin: '0 0 0.5rem' }}>Rooms &amp; Suites</h1>
<p style={{ color: '#555', marginBottom: '2rem', maxWidth: '50ch' }}>
Comfortable stays with views of the vineyard and cloud forest.
</p>
{loading && <p style={{ color: '#a6683c' }}>Loading rooms...</p>}
{loading && <p style={{ color: warm }}>Loading rooms...</p>}
{error && <p style={{ color: '#c23b22' }}>{error}</p>}
{!loading && displayedRooms.length === 0 && <p style={{ color: '#777' }}>No rooms available yet.</p>}
{!loading && rooms.length === 0 && <p style={{ color: '#777' }}>No rooms available yet.</p>}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 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 comments = commentsByRoom[room.id] || [];
const isExpanded = expandedRoom === room.id;
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '1.5rem' }}>
{rooms.map((room) => (
<article
key={room.id}
style={{
background: cream,
borderRadius: '20px',
overflow: 'hidden',
boxShadow: '0 2px 8px rgba(1,13,30,0.08)',
display: 'flex',
flexDirection: 'column',
transition: 'transform 0.2s, box-shadow 0.2s',
}}
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
return (
<article
key={room.id}
style={{
height: '220px',
background: '#ddd',
backgroundImage: room.photos?.length > 0 ? `url(${room.photos[0]})` : undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
background: cream,
borderRadius: '20px',
overflow: 'hidden',
boxShadow: '0 2px 8px rgba(1,13,30,0.08)',
display: 'flex',
flexDirection: 'column',
transition: 'transform 0.2s, box-shadow 0.2s',
}}
/>
<div style={{ padding: '1.5rem', flex: 1, display: 'flex', flexDirection: 'column' }}>
<h2 style={{ margin: '0 0 0.5rem', color: navy, fontSize: '22px' }}>{room.name}</h2>
<p style={{ color: '#555', lineHeight: 1.5, flex: 1 }}>{room.description}</p>
<div style={{ marginTop: '1rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: warm, fontWeight: 700, fontSize: '18px' }}>${room.price}</span>
<span style={{ fontSize: '13px', color: '#777' }}>per night</span>
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}`}
/>
))}
</div>
)}
</div>
</div>
</article>
))}
<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>
<div style={{ marginTop: '1rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: warm, fontWeight: 700, fontSize: '18px' }}>${room.price}</span>
<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>
)}
<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',
}}
>
{isExpanded ? 'Hide comments' : `Comments (${comments.length})`}
</button>
{isExpanded && (
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{user ? (
user.comments_disabled ? (
<p style={{ color: '#c23b22', fontSize: '14px' }}>Comments are disabled for your account.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<textarea
value={draft}
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' }}
/>
<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' }}
>
{sending ? 'Sending...' : 'Post'}
</button>
</div>
</div>
)
) : (
<p style={{ color: '#777', fontSize: '14px' }}>Log in to leave a comment.</p>
)}
{comments.length === 0 ? (
<p style={{ color: '#777', fontSize: '14px' }}>No comments yet.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{comments.map((c) => (
<div key={c.id} style={{ background: '#fff', padding: '0.75rem', borderRadius: '10px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.3rem' }}>
<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>
</div>
<p style={{ margin: 0, color: '#555', fontSize: '14px' }}>{c.text}</p>
{user?.role === 'admin' && (
<button
onClick={() => deleteComment(room.id, c.id)}
style={{ marginTop: '0.5rem', fontSize: '12px', color: '#c23b22', background: 'none', border: 'none', cursor: 'pointer' }}
>
Delete
</button>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
</article>
);
})}
</div>
</main>
);
}
const warm = '#a6683c';