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>
);
}