feat: room photo gallery, featured photo, and comments moderation
This commit is contained in:
+190
-14
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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] });
|
||||
|
||||
+215
-37
@@ -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 & 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';
|
||||
|
||||
Reference in New Issue
Block a user