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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user