fix: admin panel save issues - POST/PUT URLs, users list, image upload

This commit is contained in:
2026-06-28 23:01:13 -05:00
parent 7f81833936
commit cac82e6d9d
+221 -22
View File
@@ -473,18 +473,24 @@ function Confirm({ message, onConfirm, onCancel }: any) {
}
/* ─── Slides Section ─── */
function SlidesSection({ slides, setSlides, images, onToast }: any) {
function SlidesSection({ slides, setSlides, images, onToast, onRefreshImages }: any) {
const [editing, setEditing] = useState<Slide | null>(null);
const [confirmDel, setConfirmDel] = useState<number | null>(null);
const saveSlide = async (slide: Slide) => {
try {
const res = await fetch(`/api/slides/${slide.id}`, {
const url = slide.id ? `/api/slides/${slide.id}` : '/api/slides';
const res = await fetch(url, {
method: slide.id ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(slide),
});
if (res.ok) { setEditing(null); setSlides((s: any) => s.map((s: Slide) => s.id === slide.id ? slide : s)); onToast('Slide saved!', 'success'); }
if (res.ok) {
const data = await res.json();
setEditing(null);
setSlides((s: any) => slide.id ? s.map((x: Slide) => x.id === slide.id ? data.data : x) : [...s, data.data]);
onToast('Slide saved!', 'success');
}
} catch { onToast('Error saving slide', 'error'); }
};
@@ -516,16 +522,41 @@ function SlidesSection({ slides, setSlides, images, onToast }: any) {
</div>
)}
{editing && (
<SlideEditor slide={editing} setSlide={setEditing} images={images} onSave={saveSlide} onCancel={() => setEditing(null)} />
<SlideEditor slide={editing} setSlide={setEditing} images={images} onRefreshImages={onRefreshImages} onSave={saveSlide} onCancel={() => setEditing(null)} />
)}
{confirmDel !== null && <Confirm message="Delete this slide?" onCancel={() => setConfirmDel(null)} onConfirm={() => delSlide(confirmDel)} />}
</div>
);
}
function SlideEditor({ slide, setSlide, images, onSave, onCancel }: any) {
function SlideEditor({ slide, setSlide, images, onRefreshImages, onSave, onCancel }: any) {
const [showPicker, setShowPicker] = useState(false);
const [uploading, setUploading] = useState(false);
const s = { ...slide };
const handleUpload = async (files: FileList) => {
setUploading(true);
try {
for (const file of Array.from(files)) {
const b64 = await readFileBase64(file);
const res = await fetch('/api/admin/images', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: file.name, data: b64, category: 'slides', room_id: null, location_target: 'slides' }),
});
if (res.ok) {
const data = await res.json();
onRefreshImages?.();
setSlide({ ...s, image_id: data.data.id, image_url: data.data.data });
}
}
} catch (err) {
console.error('Upload failed:', err);
} finally {
setUploading(false);
}
};
return (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9998 }}>
<div style={{ background: '#FFFFFF', borderRadius: 16, padding: '24px 28px', maxWidth: 500, width: '90%', maxHeight: '90vh', overflowY: 'auto' }}>
@@ -537,6 +568,10 @@ function SlideEditor({ slide, setSlide, images, onSave, onCancel }: any) {
<label style={{ fontSize: '13px', fontWeight: 600, color: C.text }}>Image</label>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<Btn onClick={() => setShowPicker(!showPicker)} size="sm" variant="secondary">{showPicker ? 'Hide picker' : 'Pick from gallery'}</Btn>
<label style={{ cursor: 'pointer' }}>
<input type="file" accept="image/*" multiple style={{ display: 'none' }} onChange={(e) => { if (e.target.files?.length) handleUpload(e.target.files); e.target.value = ''; }} />
<Btn size="sm" variant="secondary" disabled={uploading}>{uploading ? 'Uploading...' : 'Upload from computer'}</Btn>
</label>
{s.image_id && images.find((i: Image) => i.id === s.image_id) && (
<img src={images.find((i: Image) => i.id === s.image_id)!.data} style={{ width: 80, height: 56, objectFit: 'cover', borderRadius: 8, border: `2px solid ${C.gold}` }} />
)}
@@ -547,7 +582,7 @@ function SlideEditor({ slide, setSlide, images, onSave, onCancel }: any) {
<img key={img.id} src={img.data} alt="" onClick={() => { setSlide({ ...s, image_id: img.id, image_url: img.data }); setShowPicker(false); }}
style={{ width: 64, height: 48, objectFit: 'cover', borderRadius: 6, cursor: 'pointer', border: s.image_id === img.id ? `2px solid ${C.gold}` : '1px solid #eee' }} />
))}
{images.filter((i: Image) => i.category === 'slides').length === 0 && <span style={{ fontSize: '12px', color: C.textLight, padding: '8px' }}>No images uploaded</span>}
{images.filter((i: Image) => i.category === 'slides').length === 0 && <span style={{ fontSize: '12px', color: C.textLight, padding: '8px' }}>No images uploaded. Use "Upload from computer" above.</span>}
</div>
)}
</div>
@@ -572,12 +607,18 @@ function CalendarSection({ events, setEvents, images, onToast }: any) {
const save = async (ev: CalendarEvent) => {
try {
const res = await fetch(`/api/calendar_events/${ev.id}`, {
const url = ev.id ? `/api/calendar_events/${ev.id}` : '/api/calendar_events';
const res = await fetch(url, {
method: ev.id ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ev),
});
if (res.ok) { setEditing(null); setEvents((e: any) => e.map((x: CalendarEvent) => x.id === ev.id ? ev : x)); onToast('Event saved!', 'success'); }
if (res.ok) {
const data = await res.json();
setEditing(null);
setEvents((e: any) => ev.id ? e.map((x: CalendarEvent) => x.id === ev.id ? data.data : x) : [...e, data.data]);
onToast('Event saved!', 'success');
}
} catch { onToast('Error', 'error'); }
};
@@ -642,12 +683,18 @@ function SocialSection({ events, setEvents, images, onToast }: any) {
const save = async (ev: SocialEvent) => {
try {
const res = await fetch(`/api/social_events/${ev.id}`, {
const url = ev.id ? `/api/social_events/${ev.id}` : '/api/social_events';
const res = await fetch(url, {
method: ev.id ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ev),
});
if (res.ok) { setEditing(null); setEvents((e: any) => e.map((x: SocialEvent) => x.id === ev.id ? ev : x)); onToast('Event saved!', 'success'); }
if (res.ok) {
const data = await res.json();
setEditing(null);
setEvents((e: any) => ev.id ? e.map((x: SocialEvent) => x.id === ev.id ? data.data : x) : [...e, data.data]);
onToast('Event saved!', 'success');
}
} catch { onToast('Error', 'error'); }
};
@@ -748,12 +795,18 @@ function RoomsSection({ rooms, setRooms, images, onToast }: any) {
const save = async (room: Room) => {
try {
const res = await fetch(`/api/rooms/${room.id}`, {
const url = room.id ? `/api/rooms/${room.id}` : '/api/rooms';
const res = await fetch(url, {
method: room.id ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(room),
});
if (res.ok) { setEditing(null); setRooms((r: any) => r.map((x: Room) => x.id === room.id ? room : x)); onToast('Room saved!', 'success'); }
if (res.ok) {
const data = await res.json();
setEditing(null);
setRooms((r: any) => room.id ? r.map((x: Room) => x.id === room.id ? data.data : x) : [...r, data.data]);
onToast('Room saved!', 'success');
}
} catch { onToast('Error', 'error'); }
};
@@ -1079,12 +1132,18 @@ function PlacesSection({ places, setPlaces, images, onToast }: any) {
const save = async (place: Place) => {
try {
const res = await fetch(`/api/places/${place.id}`, {
const url = place.id ? `/api/places/${place.id}` : '/api/places';
const res = await fetch(url, {
method: place.id ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(place),
});
if (res.ok) { setEditing(null); setPlaces((p: any) => p.map((x: Place) => x.id === place.id ? place : x)); onToast('Place saved!', 'success'); }
if (res.ok) {
const data = await res.json();
setEditing(null);
setPlaces((p: any) => place.id ? p.map((x: Place) => x.id === place.id ? data.data : x) : [...p, data.data]);
onToast('Place saved!', 'success');
}
} catch { onToast('Error', 'error'); }
};
@@ -1302,15 +1361,148 @@ function Dashboard({ rooms, slides, events, reviews, places }: any) {
}
/* ─── Users Section ─── */
function UsersSection() {
function UsersSection({ onToast }: { onToast: (msg: string, type: string) => void }) {
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<any | null>(null);
const [newUser, setNewUser] = useState({ username: '', password: '', role: 'user' });
const [showAdd, setShowAdd] = useState(false);
useEffect(() => {
loadUsers();
}, []);
const loadUsers = async () => {
try {
const res = await fetch('/api/admin/users');
if (res.ok) {
const data = await res.json();
setUsers(data.data || []);
}
} catch (err) {
console.error('Failed to load users:', err);
} finally {
setLoading(false);
}
};
const createUser = async () => {
if (!newUser.username || !newUser.password) {
onToast('Username and password required', 'error');
return;
}
try {
const res = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newUser),
});
if (res.ok) {
onToast('User created!', 'success');
setNewUser({ username: '', password: '', role: 'user' });
setShowAdd(false);
loadUsers();
} else {
const err = await res.json();
onToast(err.error || 'Failed to create user', 'error');
}
} catch {
onToast('Error creating user', 'error');
}
};
const updateUser = async (user: any) => {
try {
const res = await fetch('/api/admin/users', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user),
});
if (res.ok) {
onToast('User updated!', 'success');
setEditing(null);
loadUsers();
} else {
const err = await res.json();
onToast(err.error || 'Failed to update user', 'error');
}
} catch {
onToast('Error updating user', 'error');
}
};
const deleteUser = async (id: number) => {
try {
const res = await fetch(`/api/admin/users?id=${id}`, { method: 'DELETE' });
if (res.ok) {
onToast('User deleted', 'success');
loadUsers();
}
} catch {
onToast('Error deleting user', 'error');
}
};
if (loading) {
return <div style={{ textAlign: 'center', padding: '40px' }}>Loading...</div>;
}
return (
<div>
<SecHead title="👥 Users" />
<div style={{ textAlign: 'center', padding: '40px', color: C.textLight }}>
<div style={{ fontSize: '48px', marginBottom: '8px' }}>👥</div>
<div style={{ fontSize: '16px', fontWeight: 600, color: C.text }}>User management</div>
<p style={{ fontSize: '13px' }}>Manage staff accounts and permissions.</p>
<SecHead title="👥 Users" count={users.length}
action={<Btn onClick={() => setShowAdd(!showAdd)} size="sm">+ Add User</Btn>} />
{showAdd && (
<div style={{ background: C.card, borderRadius: 12, padding: 16, marginBottom: 16 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12 }}>
<I label="Username" value={newUser.username} onChange={(v: string) => setNewUser({ ...newUser, username: v })} />
<I label="Password" type="password" value={newUser.password} onChange={(v: string) => setNewUser({ ...newUser, password: v })} />
<div>
<label style={{ fontSize: 13, fontWeight: 600, color: C.text, display: 'block', marginBottom: 6 }}>Role</label>
<select value={newUser.role} onChange={(e) => setNewUser({ ...newUser, role: e.target.value })}
style={{ width: '100%', padding: '10px 12px', borderRadius: 8, border: `1px solid ${C.border}`, fontSize: 14 }}>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
<Btn onClick={createUser} size="sm">Create User</Btn>
<Btn variant="secondary" onClick={() => setShowAdd(false)} size="sm">Cancel</Btn>
</div>
</div>
)}
<div style={{ display: 'grid', gap: '12px' }}>
{users.map((u: any) => (
<div key={u.id} style={{ background: C.card, borderRadius: 12, padding: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontWeight: 600, color: C.text }}>{u.username} <span style={{ fontSize: 12, background: u.role === 'admin' ? '#dc2626' : '#059669', color: '#fff', padding: '2px 8px', borderRadius: 4, marginLeft: 8 }}>{u.role}</span></div>
<div style={{ fontSize: 12, color: C.textLight }}>{u.first_name || ''} {u.last_name || ''} {u.comments_disabled ? '• Comments disabled' : ''}</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<Btn size="sm" variant="secondary" onClick={() => setEditing(u)}>Edit</Btn>
</div>
</div>
))}
</div>
{editing && (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9998 }}>
<div style={{ background: '#fff', borderRadius: 16, padding: 24, maxWidth: 400, width: '90%' }}>
<h3 style={{ margin: '0 0 20px', fontSize: 18, fontWeight: 700 }}>Edit User</h3>
<div style={{ display: 'grid', gap: 12 }}>
<I label="First Name" value={editing.first_name || ''} onChange={(v: string) => setEditing({ ...editing, first_name: v })} />
<I label="Last Name" value={editing.last_name || ''} onChange={(v: string) => setEditing({ ...editing, last_name: v })} />
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={editing.comments_disabled || false} onChange={(e) => setEditing({ ...editing, comments_disabled: e.target.checked })} />
<label style={{ fontSize: 13, color: C.text }}>Disable comments</label>
</div>
</div>
<div style={{ marginTop: 16, display: 'flex', gap: 8 }}>
<Btn onClick={() => updateUser(editing)} size="sm">Save</Btn>
<Btn variant="secondary" onClick={() => setEditing(null)} size="sm">Cancel</Btn>
</div>
</div>
</div>
)}
</div>
);
}
@@ -1518,6 +1710,13 @@ export default function AdminPage() {
}, []);
/* ── Data fetching ── */
const loadImages = async () => {
try {
const res = await fetch('/api/admin/images');
if (res.ok) { const d = await res.json(); setImages(Array.isArray(d) ? d : (d.images || [])); }
} catch (err) { console.error('Failed to load images:', err); }
};
useEffect(() => {
if (!isAuthenticated) return;
const fetchAll = async () => {
@@ -1563,14 +1762,14 @@ export default function AdminPage() {
<ImageGallery images={images} onDelete={deleteImage} />
</div>
);
case 'slides': return <SlidesSection slides={slides} setSlides={setSlides} images={images} onToast={showToast} />;
case 'slides': return <SlidesSection slides={slides} setSlides={setSlides} images={images} onToast={showToast} onRefreshImages={loadImages} />;
case 'calendar': return <CalendarSection events={events} setEvents={setEvents} images={images} onToast={showToast} />;
case 'social': return <SocialSection events={socialEvents} setEvents={setSocialEvents} images={images} onToast={showToast} />;
case 'rooms': return <RoomsSection rooms={rooms} setRooms={setRooms} images={images} onToast={showToast} />;
case 'menu': return <MenuSection items={menuItems} setItems={setMenuItems} images={images} onToast={showToast} />;
case 'places': return <PlacesSection places={places} setPlaces={setPlaces} images={images} onToast={showToast} />;
case 'reviews': return <ReviewsSection reviews={reviews} setReviews={setReviews} onToast={showToast} />;
case 'users': return <UsersSection />;
case 'users': return <UsersSection onToast={showToast} />;
case 'weather': return <WeatherSection onToast={showToast} />;
default: return <Dashboard rooms={rooms} slides={slides} events={events} reviews={reviews} places={places} />;
}