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
+190 -14
View File
@@ -16,6 +16,7 @@ interface Room {
description: string;
price: string;
photos: string[];
featured_photo: string | null;
active: boolean;
sort_order: number;
}
@@ -25,11 +26,32 @@ interface RoomForm {
name: string;
description: string;
price: string;
photos: string | string[];
photos: string[];
featured_photo: string | null;
active: boolean;
sort_order: number;
}
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;
}
interface MenuItem {
id: number;
category: string;
@@ -65,6 +87,9 @@ interface User {
id: number;
username: string;
role: 'admin' | 'user';
first_name: string | null;
last_name: string | null;
comments_disabled: boolean;
}
interface Slide {
@@ -103,8 +128,12 @@ export default function AdminPage() {
const [tagline, setTagline] = useState('Hotel · Restaurant · Andean Highlands');
const [rooms, setRooms] = useState<Room[]>([]);
const [roomForm, setRoomForm] = useState<RoomForm>({ name: '', description: '', price: '', photos: '', active: true, sort_order: 0 });
const [roomForm, setRoomForm] = useState<RoomForm>({ name: '', description: '', price: '', photos: [], featured_photo: null, active: true, sort_order: 0 });
const [editingRoom, setEditingRoom] = useState<number | null>(null);
const [roomComments, setRoomComments] = useState<RoomComment[]>([]);
const [roomCommentRoomId, setRoomCommentRoomId] = useState<number | null>(null);
const [editingUser, setEditingUser] = useState<number | null>(null);
const [userEditForm, setUserEditForm] = useState({ first_name: '', last_name: '', comments_disabled: false });
const [menu, setMenu] = useState<MenuItem[]>([]);
const [menuForm, setMenuForm] = useState<Partial<MenuItem>>({ category: '', name: '', description: '', price: '', is_daily_special: false, active: true, sort_order: 0 });
@@ -240,9 +269,7 @@ export default function AdminPage() {
const saveRoom = async (e: React.FormEvent) => {
e.preventDefault();
const photosValue = roomForm.photos || [];
const photosArray: string[] = typeof photosValue === 'string' ? (photosValue as unknown as string).split(',').map((s: string) => s.trim()).filter(Boolean) : photosValue;
const payload = { ...roomForm, photos: photosArray, active: roomForm.active !== false, sort_order: roomForm.sort_order || 0 };
const payload = { ...roomForm, photos: roomForm.photos || [], featured_photo: roomForm.featured_photo || null, active: roomForm.active !== false, sort_order: roomForm.sort_order || 0 };
if (editingRoom) {
await api(`/api/rooms/${editingRoom}`, 'PUT', payload);
setRooms((prev) => prev.map((r) => (r.id === editingRoom ? { ...r, ...payload } as Room : r)));
@@ -251,11 +278,11 @@ export default function AdminPage() {
const json = await api('/api/rooms', 'POST', payload);
setRooms((prev) => [...prev, json.data]);
}
setRoomForm({ name: '', description: '', price: '', photos: '', active: true, sort_order: 0 });
setRoomForm({ name: '', description: '', price: '', photos: [], featured_photo: null, active: true, sort_order: 0 });
notify('Room saved.');
};
const editRoom = (r: Room) => { setRoomForm({ ...r, photos: r.photos?.join(', ') || '' }); setEditingRoom(r.id); };
const editRoom = (r: Room) => { setRoomForm({ ...r, photos: r.photos || [], featured_photo: r.featured_photo || null }); setEditingRoom(r.id); };
const deleteRoom = async (id: number) => { await api(`/api/rooms/${id}`, 'DELETE'); setRooms((prev) => prev.filter((r) => r.id !== id)); };
const saveMenu = async (e: React.FormEvent) => {
@@ -301,6 +328,35 @@ export default function AdminPage() {
const deleteReview = async (id: number) => { await api(`/api/admin/reviews/${id}`, 'DELETE'); setReviews((prev) => prev.filter((r) => r.id !== id)); };
const loadRoomComments = async (roomId: number) => {
setRoomCommentRoomId(roomId);
const res = await fetch(`/api/rooms/${roomId}/comments`, { credentials: 'same-origin' });
const json = await res.json().catch(() => ({ data: [] }));
setRoomComments(json.data || []);
};
const deleteRoomComment = async (commentId: number) => {
await api(`/api/admin/comments/${commentId}`, 'DELETE');
setRoomComments((prev) => prev.filter((c) => c.id !== commentId));
};
const toggleUserComments = async (user: User) => {
const next = !user.comments_disabled;
await api(`/api/admin/users/${user.id}`, 'PUT', { comments_disabled: next });
setUsers((prev) => prev.map((u) => (u.id === user.id ? { ...u, comments_disabled: next } : u)));
notify(`User ${next ? 'disabled' : 'enabled'} for comments.`);
};
const saveUserNames = async (user: User) => {
await api(`/api/admin/users/${user.id}`, 'PUT', {
first_name: userEditForm.first_name,
last_name: userEditForm.last_name,
});
setUsers((prev) => prev.map((u) => (u.id === user.id ? { ...u, first_name: userEditForm.first_name, last_name: userEditForm.last_name } : u)));
setEditingUser(null);
notify('User updated.');
};
const saveUser = async (e: React.FormEvent) => {
e.preventDefault();
const json = await api('/api/admin/users', 'POST', userForm);
@@ -477,24 +533,59 @@ export default function AdminPage() {
</form>
<ListTable rows={events.map((ev) => [ev.date, ev.title, ev.type, ev.active ? 'On' : 'Off'])} onEdit={(i) => editEvent(events[i])} onDelete={(i) => deleteEvent(events[i].id)} />
</Section>
<Section title="Rooms">
<form onSubmit={saveRoom} style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', marginBottom: '1.5rem' }}>
<Text label="Name" value={roomForm.name || ''} onChange={(v) => setRoomForm({ ...roomForm, name: v })} />
<Text label="Price / night" value={roomForm.price || ''} onChange={(v) => setRoomForm({ ...roomForm, price: v })} />
<Text label="Photos (comma-separated URLs)" value={typeof roomForm.photos === 'string' ? roomForm.photos : roomForm.photos.join(', ')} onChange={(v) => setRoomForm({ ...roomForm, photos: v })} />
<Number label="Sort order" value={roomForm.sort_order ?? 0} onChange={(v) => setRoomForm({ ...roomForm, sort_order: parseInt(v || '0', 10) })} />
<Text label="Description" value={roomForm.description || ''} onChange={(v) => setRoomForm({ ...roomForm, description: v })} />
<Number label="Sort order" value={roomForm.sort_order ?? 0} onChange={(v) => setRoomForm({ ...roomForm, sort_order: parseInt(v || '0', 10) })} />
<RoomPhotoPicker
label="Room photos"
images={images}
photos={roomForm.photos || []}
featuredPhoto={roomForm.featured_photo}
onChange={(photos: string[], featured: string | null) => setRoomForm({ ...roomForm, photos, featured_photo: featured })}
/>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '14px', color: '#555' }}>
<input type="checkbox" checked={roomForm.active !== false} onChange={(e) => setRoomForm({ ...roomForm, active: e.target.checked })} />
Active
</label>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'end' }}>
<Button type="submit">{editingRoom ? 'Update room' : 'Add room'}</Button>
{editingRoom && <SecondaryButton onClick={() => { setEditingRoom(null); setRoomForm({ name: '', description: '', price: '', photos: '', active: true, sort_order: 0 }); }}>Cancel</SecondaryButton>}
{editingRoom && <SecondaryButton onClick={() => { setEditingRoom(null); setRoomForm({ name: '', description: '', price: '', photos: ([] as string[]), featured_photo: null, active: true, sort_order: 0 }); }}>Cancel</SecondaryButton>}
</div>
</form>
<ListTable rows={rooms.map((r) => [r.name, `$${r.price}`, r.description.slice(0, 60), r.active ? 'On' : 'Off', `#${r.sort_order}`])} onEdit={(i) => editRoom(rooms[i])} onDelete={(i) => deleteRoom(rooms[i].id)} />
<ListTable rows={rooms.map((r) => [r.name, `$${r.price}`, r.description.slice(0, 60), r.active ? 'On' : 'Off', `#${r.sort_order}`])}
onEdit={(i) => editRoom(rooms[i])}
onDelete={(i) => deleteRoom(rooms[i].id)}
onComments={(i) => loadRoomComments(rooms[i].id)}
/>
{roomCommentRoomId !== null && (
<div style={{ marginTop: '1.5rem', padding: '1rem', background: '#fff', borderRadius: '12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
<strong style={{ color: navy }}>Comments for room #{roomCommentRoomId}</strong>
<SecondaryButton onClick={() => { setRoomCommentRoomId(null); setRoomComments([]); }}>Close</SecondaryButton>
</div>
{roomComments.length === 0 ? (
<p style={{ color: '#777' }}>No comments yet.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{roomComments.map((c) => (
<div key={c.id} style={{ padding: '0.75rem', background: '#f5f0e8', borderRadius: '10px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.3rem' }}>
<strong>{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' }}>{c.text}</p>
<div style={{ marginTop: '0.5rem' }}>
<DangerButton onClick={() => deleteRoomComment(c.id)}>Remove comment</DangerButton>
</div>
</div>
))}
</div>
)}
</div>
)}
</Section>
<Section title="Menu">
@@ -576,7 +667,22 @@ export default function AdminPage() {
<Button type="submit">Create user</Button>
</div>
</form>
<ListTable rows={users.map((u) => [u.username, u.role])} onDelete={(i) => deleteUser(users[i].id)} />
<ListTable rows={users.map((u) => [u.username, u.role, u.first_name || '', u.last_name || '', u.comments_disabled ? 'comments disabled' : ''])} onDelete={(i) => deleteUser(users[i].id)} onEdit={(i) => { setEditingUser(users[i].id); setUserEditForm({ first_name: users[i].first_name || '', last_name: users[i].last_name || '', comments_disabled: users[i].comments_disabled }); }} />
{editingUser !== null && (
<div style={{ marginTop: '1rem', padding: '1rem', background: '#fff', borderRadius: '12px', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<strong>Edit user {users.find((u) => u.id === editingUser)?.username}</strong>
<Text label="First name" value={userEditForm.first_name} onChange={(v) => setUserEditForm({ ...userEditForm, first_name: v })} />
<Text label="Last name" value={userEditForm.last_name} onChange={(v) => setUserEditForm({ ...userEditForm, last_name: v })} />
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '14px', color: '#555' }}>
<input type="checkbox" checked={userEditForm.comments_disabled} onChange={(e) => setUserEditForm({ ...userEditForm, comments_disabled: e.target.checked })} />
Disable comments
</label>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<Button onClick={() => { const u = users.find((u) => u.id === editingUser)!; saveUserNames(u); toggleUserComments(u); }}>Save</Button>
<SecondaryButton onClick={() => setEditingUser(null)}>Cancel</SecondaryButton>
</div>
</div>
)}
</Section>
</main>
);
@@ -679,7 +785,7 @@ function DangerButton({ children, onClick }: { children: React.ReactNode; onClic
);
}
function ListTable({ rows, onEdit, onDelete }: { rows: string[][]; onEdit?: (index: number) => void; onDelete: (index: number) => void }) {
function ListTable({ rows, onEdit, onDelete, onComments }: { rows: string[][]; onEdit?: (index: number) => void; onDelete: (index: number) => void; onComments?: (index: number) => void }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{rows.map((row, i) => (
@@ -688,6 +794,7 @@ function ListTable({ rows, onEdit, onDelete }: { rows: string[][]; onEdit?: (ind
{row.map((cell, j) => <span key={j}>{cell}</span>)}
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
{onComments && <Button onClick={() => onComments(i)}>Comments</Button>}
{onEdit && <Button onClick={() => onEdit(i)}>Edit</Button>}
<DangerButton onClick={() => onDelete(i)}>Delete</DangerButton>
</div>
@@ -696,3 +803,72 @@ function ListTable({ rows, onEdit, onDelete }: { rows: string[][]; onEdit?: (ind
</div>
);
}
function RoomPhotoPicker({
label, images, photos, featuredPhoto, onChange,
}: {
label: string;
images: ImageRecord[];
photos: string[];
featuredPhoto: string | null;
onChange: (photos: string[], featured: string | null) => void;
}) {
const allPhotos = Array.from(new Set([...photos, ...images.map((i) => i.data)]));
return (
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem', fontSize: '14px', color: '#555', gridColumn: '1 / -1' }}>
{label}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(110px, 1fr))', gap: '0.75rem' }}>
{allPhotos.map((url) => {
const isSelected = photos.includes(url);
const isFeatured = featuredPhoto === url;
return (
<div
key={url}
onClick={() => {
if (isSelected) {
const next = photos.filter((p) => p !== url);
onChange(next, featuredPhoto === url ? null : featuredPhoto);
} else {
onChange([...photos, url], featuredPhoto || url);
}
}}
style={{
border: `2px solid ${isFeatured ? gold : isSelected ? navy : 'transparent'}`,
borderRadius: '12px',
overflow: 'hidden',
cursor: 'pointer',
opacity: isSelected ? 1 : 0.55,
position: 'relative',
}}
>
<img src={url} alt="" style={{ width: '100%', height: '90px', objectFit: 'cover', display: 'block' }} />
{isFeatured && (
<span style={{ position: 'absolute', top: 4, left: 4, background: gold, color: navy, fontSize: '10px', fontWeight: 700, padding: '2px 6px', borderRadius: '8px' }}>FEATURED</span>
)}
{!isSelected && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontWeight: 700, textShadow: '0 1px 3px rgba(0,0,0,0.5)' }}>+</div>
)}
</div>
);
})}
</div>
{allPhotos.length > 0 && (
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<span style={{ fontSize: '13px', color: '#777' }}>Featured photo:</span>
<select
value={featuredPhoto || ''}
onChange={(e) => onChange(photos, e.target.value || null)}
style={{ padding: '0.5rem 0.75rem', borderRadius: '8px', border: '1px solid rgba(1,13,30,0.18)', fontSize: '14px' }}
>
<option value="">(none)</option>
{photos.map((url) => (
<option key={url} value={url}>
{url.slice(0, 40)}...
</option>
))}
</select>
</div>
)}
</label>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { requireRole } from '@/lib/auth';
export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) {
try {
await requireRole('admin');
const commentId = parseInt(params.id, 10);
await db.query('UPDATE room_comments SET deleted_by_admin = TRUE WHERE id = $1', [commentId]);
return NextResponse.json({ ok: true });
} catch (err: any) {
return NextResponse.json(
{ error: err.message || 'Failed to delete comment' },
{ status: err.message === 'Unauthorized' ? 401 : 500 }
);
}
}
+37 -1
View File
@@ -5,7 +5,7 @@ import { db } from '@/lib/db';
export async function GET() {
try {
await requireRole('admin');
const { rows } = await db.query('SELECT id, username, role, created_at FROM users ORDER BY created_at DESC');
const { rows } = await db.query('SELECT id, username, role, comments_disabled, first_name, last_name, created_at FROM users ORDER BY created_at DESC');
return NextResponse.json({ data: rows });
} catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to fetch users' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
@@ -32,3 +32,39 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: err.message || 'Failed to create user' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
}
}
export async function PUT(request: NextRequest) {
try {
await requireRole('admin');
const body = await request.json();
const { id, first_name, last_name, comments_disabled } = body;
const { rows } = await db.query(
'UPDATE users SET first_name = $1, last_name = $2, comments_disabled = $3 WHERE id = $4 RETURNING id, username, role, first_name, last_name, comments_disabled, created_at',
[first_name || null, last_name || null, comments_disabled === true, id]
);
if (rows.length === 0) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
return NextResponse.json({ data: rows[0] });
} catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to update user' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
}
}
export async function DELETE(request: NextRequest) {
try {
await requireRole('admin');
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) {
return NextResponse.json({ error: 'User ID required' }, { status: 400 });
}
await db.query('DELETE FROM users WHERE id = $1', [id]);
return NextResponse.json({ ok: true });
} catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to delete user' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
}
}
+15 -1
View File
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export async function GET() {
@@ -6,5 +7,18 @@ export async function GET() {
if (!session) {
return NextResponse.json({ authenticated: false }, { status: 401 });
}
return NextResponse.json({ authenticated: true, role: session.role, username: session.username });
const { rows } = await db.query(
'SELECT id, username, role, first_name, last_name, comments_disabled FROM users WHERE id = $1',
[session.id]
);
const user = rows[0] || {};
return NextResponse.json({
authenticated: true,
role: session.role,
username: session.username,
id: session.id,
first_name: user.first_name || null,
last_name: user.last_name || null,
comments_disabled: user.comments_disabled === true,
});
}
+62
View File
@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { requireAuth, canComment } from '@/lib/auth';
export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
try {
const roomId = parseInt(params.id, 10);
const { searchParams } = new URL(request.url);
const photoUrl = searchParams.get('photo_url') || null;
const { rows } = await db.query(
`SELECT id, room_id, photo_url, user_id, first_name, last_initial, text, created_at, deleted_by_admin
FROM room_comments
WHERE room_id = $1 AND ($2::varchar IS NULL OR photo_url = $2) AND deleted_by_admin = FALSE
ORDER BY created_at DESC`,
[roomId, photoUrl]
);
return NextResponse.json({ data: rows });
} catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to fetch comments' }, { status: 500 });
}
}
export async function POST(request: NextRequest, { params }: { params: { id: string } }) {
try {
const session = await requireAuth();
const allowed = await canComment(session.id);
if (!allowed) {
return NextResponse.json({ error: 'Comments are disabled for your account' }, { status: 403 });
}
const roomId = parseInt(params.id, 10);
const body = await request.json();
const { photo_url, text } = body;
if (!text || !text.trim()) {
return NextResponse.json({ error: 'Comment text is required' }, { status: 400 });
}
const { rows } = await db.query(
`SELECT first_name, last_name FROM users WHERE id = $1`,
[session.id]
);
const user = rows[0] || {};
const firstName = user.first_name || session.username || 'Guest';
const lastInitial = user.last_name ? user.last_name.charAt(0).toUpperCase() : null;
const { rows: inserted } = await db.query(
`INSERT INTO room_comments (room_id, photo_url, user_id, first_name, last_initial, text)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
[roomId, photo_url || null, session.id, firstName, lastInitial, text.trim()]
);
return NextResponse.json({ data: inserted[0] });
} catch (err: any) {
return NextResponse.json(
{ error: err.message || 'Failed to post comment' },
{ status: err.message === 'Unauthorized' ? 401 : 500 }
);
}
}
+4 -4
View File
@@ -7,12 +7,12 @@ export async function PUT(request: NextRequest, { params }: { params: { id: stri
await requireRole('admin');
const id = parseInt(params.id, 10);
const body = await request.json();
const { name, description, price, photos, active, sort_order } = body;
const { name, description, price, photos, active, sort_order, featured_photo } = body;
const { rows } = await db.query(
`UPDATE rooms SET name = $1, description = $2, price = $3, photos = $4, active = $5, sort_order = $6, updated_at = NOW()
WHERE id = $7 RETURNING *`,
[name, description, price, photos || [], active, sort_order || 0, id]
`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) {
+4 -4
View File
@@ -16,17 +16,17 @@ export async function POST(request: NextRequest) {
try {
await requireRole('admin');
const body = await request.json();
const { name, description, price, photos, sort_order } = body;
const { name, description, price, photos, sort_order, featured_photo } = body;
if (!name || !description || price === undefined) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
const { rows } = await db.query(
`INSERT INTO rooms (name, description, price, photos, sort_order)
VALUES ($1, $2, $3, $4, $5)
`INSERT INTO rooms (name, description, price, photos, sort_order, featured_photo)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[name, description, price, photos || [], sort_order || 0]
[name, description, price, photos || [], sort_order || 0, featured_photo || null]
);
return NextResponse.json({ data: rows[0] });
+189 -11
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,25 +54,65 @@ 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) => (
return (
<article
key={room.id}
style={{
@@ -58,28 +127,137 @@ 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: '220px',
height: '260px',
background: '#ddd',
backgroundImage: room.photos?.length > 0 ? `url(${room.photos[0]})` : undefined,
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 style={{ padding: '1.5rem', flex: 1, display: 'flex', flexDirection: 'column' }}>
<h2 style={{ margin: '0 0 0.5rem', color: navy, fontSize: '22px' }}>{room.name}</h2>
<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';
+5
View File
@@ -71,3 +71,8 @@ export async function requireAuth() {
}
return session;
}
export async function canComment(userId: number) {
const { rows } = await db.query('SELECT comments_disabled FROM users WHERE id = $1', [userId]);
return rows.length === 0 || !rows[0].comments_disabled;
}
+34 -8
View File
@@ -136,14 +136,40 @@ export async function initSchema() {
`);
await db.query(`
CREATE TABLE IF NOT EXISTS calendar_events (
CREATE TABLE IF NOT EXISTS room_comments (
id SERIAL PRIMARY KEY,
date DATE NOT NULL,
title VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL CHECK (type IN ('holiday', 'season', 'weather', 'event')),
description TEXT,
color VARCHAR(50) DEFAULT '#E8A849',
active BOOLEAN DEFAULT TRUE,
room_id INTEGER REFERENCES rooms(id) ON DELETE CASCADE,
photo_url VARCHAR(500),
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
first_name VARCHAR(100) NOT NULL,
last_initial VARCHAR(10),
text TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
deleted_by_admin BOOLEAN DEFAULT FALSE
);
`);
await db.query(`
ALTER TABLE rooms ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS comments_disabled BOOLEAN DEFAULT FALSE;
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS first_name VARCHAR(100);
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name VARCHAR(100);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS user_sessions (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
`);
@@ -255,7 +281,7 @@ export async function seed() {
}
export async function resetDatabase() {
await db.query('DROP TABLE IF EXISTS reviews, places, menu_items, rooms, config, benefits, offers, coupons, reservations, images, users, slides, calendar_events CASCADE');
await db.query('DROP TABLE IF EXISTS room_comments, reviews, places, menu_items, rooms, config, benefits, offers, coupons, reservations, images, users, slides, calendar_events CASCADE');
await initSchema();
await seed();
}