Files
la-huasca-ts/src/app/admin/page.tsx
T

1858 lines
88 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
type Image = { id: number; filename: string; data: string; category: string; room_id: number | null; location_target: string; uploaded_at: string };
type Slide = { id: number; title: string; subtitle: string; image_url: string | null; image_id: number | null; active: boolean; sort_order: number };
type Room = { id: number; name: string; description: string; price: string; photos: string[]; featured_photo: string | null; active: boolean; sort_order: number };
type CalendarEvent = { id: number; date: string; title: string; type: string; color: string; description: string | null; active: boolean };
type SocialEvent = { id: number; name: string; description: string; price: string | null; date: string | null; photos: string[]; featured_photo: string | null; active: boolean; sort_order: number };
type MenuItem = { id: number; category: string; name: string; description: string; price: string; is_daily_special: boolean; active: boolean; sort_order: number };
type Place = { id: number; name: string; description: string; photos: string[]; featured_photo: string | null; distance_km: string; visit_time: string; suggestion: string; active: boolean; sort_order: number };
type Review = { id: number; author_name: string; author_email: string | null; rating: number; text: string; approved: boolean };
type FooterConfig = { logo: string; favicon: string; tagline: string; facebook_url: string; instagram_url: string; whatsapp_url: string; address: string; phone: string; email: string };
type SocialRes = { id: number; event_id: number; username: string; event_name: string; guests: number; notes: string; status: string; created_at: string };
const C = {
gold: '#D4A853', goldLight: 'rgba(212,168,83,0.12)', navy: '#010D1E',
warm: '#7A5A3A', bg: '#F7F4EF', card: '#FFFFFF', border: 'rgba(1,13,30,0.08)',
text: '#444444', textLight: '#888888', danger: '#D32F2F', success: '#388E3C',
shadow: '0 1px 4px rgba(1,13,30,0.06)', shadowHover: '0 6px 20px rgba(1,13,30,0.12)',
};
const NAV = [
{ id: 'brand', label: 'Brand', icon: '🎨' },
{ id: 'gallery', label: 'Images', icon: '🖼️' },
{ id: 'slides', label: 'Slideshow', icon: '🎞️' },
{ id: 'calendar', label: 'Calendar', icon: '📅' },
{ id: 'social', label: 'Social Events', icon: '🎪' },
{ id: 'rooms', label: 'Rooms', icon: '🏨' },
{ id: 'menu', label: 'Menu', icon: '🍽️' },
{ id: 'places', label: 'Places', icon: '🗺️' },
{ id: 'reviews', label: 'Reviews', icon: '⭐' },
{ id: 'users', label: 'Users', icon: '👥' },
{ id: 'weather', label: 'Weather', icon: '🌤️' },
];
function I({ label, value, onChange, type = 'text', placeholder, style }: any) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{label && <label style={{ fontSize: '13px', fontWeight: 600, color: C.text }}>{label}</label>}
<input type={type} value={value ?? ''} onChange={e => onChange(e.target.value)} placeholder={placeholder}
style={{ padding: '8px 12px', borderRadius: 8, border: `1px solid ${C.border}`, fontSize: '14px', outline: 'none', background: '#FAFAFA', ...style }}
onFocus={e => { (e.target.style as any).borderColor = C.gold; (e.target.style as any).background = '#FFFFFF'; }}
onBlur={e => { (e.target.style as any).borderColor = C.border; (e.target.style as any).background = '#FAFAFA'; }} />
</div>
);
}
function TA({ label, value, onChange, rows = 3 }: any) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{label && <label style={{ fontSize: '13px', fontWeight: 600, color: C.text }}>{label}</label>}
<textarea value={value ?? ''} onChange={e => onChange(e.target.value)} rows={rows}
style={{ padding: '8px 12px', borderRadius: 8, border: `1px solid ${C.border}`, fontSize: '14px', outline: 'none', background: '#FAFAFA', resize: 'vertical', fontFamily: 'inherit' }}
onFocus={e => { (e.target.style as any).borderColor = C.gold; (e.target.style as any).background = '#FFFFFF'; }}
onBlur={e => { (e.target.style as any).borderColor = C.border; (e.target.style as any).background = '#FAFAFA'; }} />
</div>
);
}
function S({ label, value, onChange, options, style }: any) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{label && <label style={{ fontSize: '13px', fontWeight: 600, color: C.text }}>{label}</label>}
<select value={value ?? ''} onChange={e => onChange(e.target.value)}
style={{ padding: '8px 12px', borderRadius: 8, border: `1px solid ${C.border}`, fontSize: '14px', outline: 'none', background: '#FAFAFA' }}>
{options.map((o: any) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
);
}
function Tgl({ label, checked, onChange }: any) {
return (
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', fontSize: '14px', color: C.text }}>
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)}
style={{ width: 18, height: 18, accentColor: C.gold, cursor: 'pointer' }} />
{label}
</label>
);
}
function Btn({ children, onClick, variant = 'primary', size = 'md', disabled }: any) {
const sz = size === 'sm' ? { padding: '5px 10px', fontSize: '12px' } : { padding: '8px 16px', fontSize: '14px' };
const bg = variant === 'danger' ? C.danger : variant === 'secondary' ? '#E8E4DF' : C.gold;
const color = variant === 'secondary' ? C.text : '#fff';
return (
<button onClick={onClick} disabled={disabled} style={{ ...sz, borderRadius: 8, border: 'none', cursor: disabled ? 'default' : 'pointer',
background: bg, color, fontWeight: 600, opacity: disabled ? 0.5 : 1, transition: 'all 200ms' }}
onMouseEnter={e => { if (!disabled) { e.currentTarget.style.opacity = '0.85'; e.currentTarget.style.transform = 'translateY(-1px)'; } }}
onMouseLeave={e => { if (!disabled) { e.currentTarget.style.opacity = '1'; e.currentTarget.style.transform = 'translateY(0)'; } }}>
{children}
</button>
);
}
function Badge({ children, color: c }: any) {
return (
<span style={{ padding: '2px 10px', borderRadius: 20, fontSize: '12px', fontWeight: 600,
background: c || C.goldLight, color: c ? '#fff' : C.gold }}>
{children}
</span>
);
}
function Toast({ message, type, onClose }: any) {
useEffect(() => { const t = setTimeout(onClose, 3500); return () => clearTimeout(t); }, [onClose]);
return (
<div style={{ position: 'fixed', top: '20px', right: '20px', padding: '12px 20px', borderRadius: 12,
background: type === 'success' ? C.success : type === 'error' ? C.danger : C.gold,
color: '#fff', boxShadow: C.shadowHover, zIndex: 9999, fontSize: '14px', fontWeight: 500,
display: 'flex', alignItems: 'center', gap: '10px' }}>
{message}
<button onClick={onClose} style={{ background: 'transparent', border: 'none', color: '#fff', cursor: 'pointer', fontSize: '18px', lineHeight: 1 }}>×</button>
</div>
);
}
function StatCard({ icon, value, label }: any) {
return (
<div style={{ background: C.card, borderRadius: 14, padding: '16px 20px', boxShadow: C.shadow,
display: 'flex', alignItems: 'center', gap: '14px', minWidth: '160px' }}>
<span style={{ fontSize: '28px' }}>{icon}</span>
<div>
<div style={{ fontSize: '22px', fontWeight: 700, color: C.navy }}>{value}</div>
<div style={{ fontSize: '12px', color: C.textLight }}>{label}</div>
</div>
</div>
);
}
function Empty({ icon, title, desc }: any) {
return (
<div style={{ textAlign: 'center', padding: '40px 20px', color: C.textLight }}>
<div style={{ fontSize: '48px', marginBottom: '8px' }}>{icon}</div>
<div style={{ fontSize: '16px', fontWeight: 600, color: C.text, marginBottom: '4px' }}>{title}</div>
{desc && <div style={{ fontSize: '13px' }}>{desc}</div>}
</div>
);
}
function SecHead({ title, count, action }: any) {
return (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px', flexWrap: 'wrap', gap: '10px' }}>
<h2 style={{ margin: 0, fontSize: '20px', fontWeight: 700, color: C.navy }}>
{title} <span style={{ fontSize: '14px', fontWeight: 400, color: C.textLight }}>({count})</span>
</h2>
{action}
</div>
);
}
/* ─── Image helpers ─── */
function readFileBase64(file: File): Promise<string> {
return new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(r.result as string);
r.onerror = rej;
r.readAsDataURL(file);
});
}
/* ─── Entity thumbnail ─── */
function Thumb({ urls, w = 200, h = 140 }: { urls: string[]; w?: number; h?: number }) {
if (!urls?.length) return <div style={{ width: w, height: h, background: '#EEE', borderRadius: 12, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#BBB' }}>No image</div>;
return <img src={urls[0]} alt="" style={{ width: w, height: h, objectFit: 'cover', borderRadius: 12 }} />;
}
/* ─── Thumbnail grid (inline, small) ─── */
function MiniThumbs({ urls, max = 3 }: { urls: string[]; max?: number }) {
if (!urls?.length) return null;
return (
<div style={{ display: 'flex', gap: '6px', marginTop: '8px' }}>
{urls.slice(0, max).map((u, i) => (
<img key={i} src={u} alt="" style={{ width: 70, height: 50, objectFit: 'cover', borderRadius: 8, cursor: 'pointer', border: '1px solid #eee' }} />
))}
{urls.length > max && <span style={{ padding: '8px', background: '#EEE', borderRadius: 8, fontSize: '12px', color: '#999' }}>+{urls.length - max}</span>}
</div>
);
}
/* ─── Section uploader (per-section image upload) ─── */
function SectionUploader({ category, room_id, label, onUploaded }: { category: string; room_id: number | null; label: string; onUploaded?: () => void }) {
const ref = useRef<HTMLInputElement>(null);
return (
<>
<Btn onClick={() => ref.current?.click()} size="sm">{label}</Btn>
<input ref={ref} type="file" accept="image/*" multiple style={{ display: 'none' }}
onChange={async (e) => {
for (const file of Array.from(e.target.files || [])) {
const b64 = await readFileBase64(file);
try {
const res = await fetch('/api/admin/images', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: file.name, data: b64, category, room_id, location_target: category }),
});
if (res.ok) onUploaded?.();
} catch (err) { console.error(err); }
}
e.target.value = '';
}} />
</>
);
}
/* ─── Organized image gallery with sections and borders ─── */
function ImageGallery({ images, onDelete }: { images: Image[]; onDelete: (id: number) => void }) {
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const [hoveredCard, setHoveredCard] = useState<number | null>(null);
const sectionCategories = [
{ key: 'slides', label: '🎞️ Slideshow Gallery', filter: (i: Image) => i.category === 'slides' || i.location_target === 'slides' },
{ key: 'calendar', label: '📅 Calendar Events Gallery', filter: (i: Image) => i.category === 'calendar' || i.location_target === 'calendar' },
{ key: 'social', label: '🎪 Social Events Gallery', filter: (i: Image) => i.category === 'social' || i.location_target === 'social' },
];
// Group room images by room location_target (e.g. "room-1", "room-2")
const roomImages = images.filter(
(i) => i.category === 'rooms' || i.location_target?.startsWith('room-')
);
const roomsByNumber: Record<string, Image[]> = roomImages.reduce((acc, img) => {
const key = img.location_target?.replace('room-', '') || 'uncategorized';
if (!acc[key]) acc[key] = [];
acc[key].push(img);
return acc;
}, {} as Record<string, Image[]>);
const filteredImages = images.filter((img) => {
if (selectedCategory === 'all') return true;
if (selectedCategory === 'rooms') return img.category === 'rooms' || img.location_target?.startsWith('room-');
return img.category === selectedCategory || img.location_target === selectedCategory;
});
const ImageCard = ({ img }: { img: Image }) => {
const isHov = hoveredCard === img.id;
return (
<div
style={{
position: 'relative', borderRadius: '10px', overflow: 'hidden',
boxShadow: isHov ? C.shadowHover : C.shadow,
border: `1px solid ${isHov ? C.gold : C.border}`,
transform: isHov ? 'translateY(-2px)' : 'none',
transition: 'all 200ms ease',
cursor: 'default',
}}
onMouseEnter={() => setHoveredCard(img.id)}
onMouseLeave={() => setHoveredCard(null)}
>
<img
src={img.data}
alt={img.filename}
style={{
width: '100%', height: '110px', objectFit: 'cover', display: 'block',
}}
/>
<div style={{
padding: '6px 8px', fontSize: '11px', color: C.text,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
background: C.card,
}}>
{img.filename}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '4px' }}>
<button
onClick={(e) => { e.stopPropagation(); onDelete(img.id); }}
style={{
width: '24px', height: '24px', borderRadius: '6px',
border: '1px solid rgba(211,47,47,0.2)',
background: isHov ? 'rgba(211,47,47,0.15)' : 'transparent',
color: C.danger, fontSize: '14px', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'all 200ms',
}}
>
×
</button>
</div>
{/* Category label overlay on hover */}
{isHov && img.location_target && (
<div style={{
position: 'absolute', bottom: '30px', left: '4px', right: '4px',
padding: '3px 6px', fontSize: '10px', color: '#fff',
background: 'rgba(1,13,30,0.75)', borderRadius: '4px',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
textAlign: 'center',
}}>
{img.location_target}
</div>
)}
</div>
);
};
const SectionBox = ({ title, count, children }: { title: string; count: number; children: React.ReactNode }) => (
<div style={{
borderRadius: '12px', overflow: 'hidden',
border: `1px solid ${C.border}`, background: '#fff',
boxShadow: C.shadow,
}}>
<div style={{
padding: '10px 16px',
background: C.goldLight,
borderBottom: `1px solid ${C.border}`,
fontWeight: 700, color: C.navy, fontSize: '14px',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<span>{title}</span>
<span style={{
fontSize: '11px', padding: '2px 10px', borderRadius: '999px',
background: C.success, color: '#fff', fontWeight: 600,
}}>{count}</span>
</div>
<div style={{ padding: '16px' }}>
{children}
</div>
</div>
);
return (
<div>
{/* Category filter bar with borders */}
<div style={{
display: 'flex', gap: '8px', marginBottom: '20px', flexWrap: 'wrap',
padding: '12px 16px', borderRadius: '12px',
border: `1px solid ${C.border}`, background: '#fff',
boxShadow: C.shadow,
}}>
{['all', 'slides', 'calendar', 'social', 'rooms', 'other'].map((cat) => (
<Btn
key={cat}
onClick={() => setSelectedCategory(cat)}
variant={selectedCategory === cat ? 'primary' : 'secondary'}
size="sm"
>
{cat === 'all' ? 'All Images' : cat.charAt(0).toUpperCase() + cat.slice(1)}
</Btn>
))}
</div>
{selectedCategory === 'all' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{/* Static sections */}
{sectionCategories.map((section) => {
const imgs = images.filter(section.filter);
if (imgs.length === 0) return null;
return (
<SectionBox key={section.key} title={section.label} count={imgs.length}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
gap: '12px',
}}>
{imgs.map((img) => <ImageCard key={img.id} img={img} />)}
</div>
</SectionBox>
);
})}
{/* Rooms section with room number subdivision */}
{Object.keys(roomsByNumber).length > 0 && (
<SectionBox title="🏨 Rooms Gallery" count={roomImages.length}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{Object.entries(roomsByNumber).map(([roomKey, imgs]) => (
<div key={roomKey}>
<div style={{
display: 'flex', alignItems: 'center', gap: '8px',
marginBottom: '10px', padding: '8px 12px',
background: '#FAFAFA', borderRadius: '8px',
border: `1px solid ${C.border}`,
}}>
<span style={{ fontSize: '13px', fontWeight: 600, color: C.navy }}>
🛏 Room {roomKey}
</span>
<span style={{
fontSize: '11px', padding: '2px 8px', borderRadius: '999px',
background: C.goldLight, color: C.gold, fontWeight: 600,
}}>{imgs.length} photo{imgs.length !== 1 ? 's' : ''}</span>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
gap: '12px',
}}>
{imgs.map((img) => <ImageCard key={img.id} img={img} />)}
</div>
</div>
))}
</div>
</SectionBox>
)}
{/* Uncategorized/other */}
{(() => {
const otherImages = images.filter(
(i) => !sectionCategories.some(s => s.filter(i)) &&
!(i.category === 'rooms' || i.location_target?.startsWith('room-'))
);
if (otherImages.length === 0) return null;
return (
<SectionBox title="📁 Uncategorized" count={otherImages.length}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
gap: '12px',
}}>
{otherImages.map((img) => <ImageCard key={img.id} img={img} />)}
</div>
</SectionBox>
);
})()}
</div>
) : (
<SectionBox title={
selectedCategory === 'rooms' ? '🏨 Rooms' :
selectedCategory === 'other' ? '📁 Other' :
selectedCategory.charAt(0).toUpperCase() + selectedCategory.slice(1)
} count={filteredImages.length}>
{filteredImages.length === 0 ? (
<div style={{ padding: '40px 20px', textAlign: 'center', color: C.textLight }}>
No images in this category
</div>
) : (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
gap: '12px',
}}>
{filteredImages.map((img) => <ImageCard key={img.id} img={img} />)}
</div>
)}
</SectionBox>
)}
</div>
);
}
/* ─── Entity card ─── */
/* ─── Entity card ─── */
function EntityCard({ title, desc, meta, thumb, actions, photoUrls }: any) {
return (
<div style={{ background: C.card, borderRadius: 16, overflow: 'hidden', boxShadow: C.shadow, transition: 'all 200ms', border: `1px solid ${C.border}` }}
onMouseEnter={e => { e.currentTarget.style.transform = 'translateY(-2px)'; e.currentTarget.style.boxShadow = C.shadowHover; }}
onMouseLeave={e => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = C.shadow; }}>
{thumb ? (
<img src={thumb} alt="" style={{ width: '100%', height: 140, objectFit: 'cover' }} />
) : photoUrls?.length ? <Thumb urls={photoUrls} /> : <div style={{ height: 100, background: '#EEE', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#CCC' }}>📷</div>}
<div style={{ padding: '14px 16px' }}>
<div style={{ fontWeight: 700, color: C.navy, fontSize: '15px', marginBottom: '4px' }}>{title}</div>
{desc && <div style={{ fontSize: '13px', color: C.text, lineHeight: 1.4, marginBottom: '6px', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{desc}</div>}
{meta && <div style={{ fontSize: '12px', color: C.textLight, marginBottom: '10px' }}>{meta}</div>}
{photoUrls?.length ? <MiniThumbs urls={photoUrls} /> : null}
{actions && <div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>{actions}</div>}
</div>
</div>
);
}
/* ─── Confirm dialog ─── */
function Confirm({ message, onConfirm, onCancel }: any) {
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: 360, width: '90%', boxShadow: '0 20px 60px rgba(0,0,0,0.2)' }}>
<p style={{ margin: '0 0 20px', fontSize: '15px', color: C.text }}>{message}</p>
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end' }}>
<Btn onClick={onCancel} variant="secondary">Cancel</Btn>
<Btn onClick={onConfirm} variant="danger">Delete</Btn>
</div>
</div>
</div>
);
}
/* ─── Slides Section ─── */
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 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) {
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'); }
};
const delSlide = async (id: number) => {
try {
await fetch(`/api/slides/${id}`, { method: 'DELETE' });
setSlides((s: any) => s.filter((x: Slide) => x.id !== id));
onToast('Slide deleted', 'success');
} catch { onToast('Error deleting slide', 'error'); }
setConfirmDel(null);
};
return (
<div>
<SecHead title="🎞️ Slideshow" count={slides.length}
action={<Btn onClick={() => setEditing({ id: 0, title: '', subtitle: '', image_url: null, image_id: null, active: true, sort_order: slides.length })} size="sm">+ New Slide</Btn>} />
{slides.length === 0 ? <Empty icon="🎞️" title="No slides yet" /> : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '16px' }}>
{slides.map((s: Slide) => (
<EntityCard key={s.id} title={s.title} desc={s.subtitle} meta={s.active ? 'Active' : 'Inactive'}
thumb={s.image_url || s.image_id ? (images.find((i: Image) => i.id === s.image_id)?.data) : null}
actions={
<>
<Btn size="sm" variant="secondary" onClick={() => setEditing(s)}>Edit</Btn>
<Btn size="sm" variant="danger" onClick={() => setConfirmDel(s.id)}>Del</Btn>
</>
} />
))}
</div>
)}
{editing && (
<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, 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' }}>
<h3 style={{ margin: '0 0 20px', fontSize: '18px', fontWeight: 700, color: C.navy }}>{s.id ? 'Edit Slide' : 'New Slide'}</h3>
<div style={{ display: 'grid', gap: '14px' }}>
<I label="Title" value={s.title} onChange={(v: string) => setSlide({ ...s, title: v })} />
<I label="Subtitle" value={s.subtitle} onChange={(v: string) => setSlide({ ...s, subtitle: v })} />
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<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}` }} />
)}
</div>
{showPicker && (
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', padding: '8px', background: '#FAFAFA', borderRadius: 8 }}>
{images.filter((i: Image) => i.category === 'slides').map((img: Image) => (
<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. Use "Upload from computer" above.</span>}
</div>
)}
</div>
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
<Tgl label="Active" checked={s.active} onChange={(v: boolean) => setSlide({ ...s, active: v })} />
<I label="Sort Order" type="number" value={s.sort_order} onChange={(v: string) => setSlide({ ...s, sort_order: Number(v) })} style={{ width: 80 }} />
</div>
</div>
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end', marginTop: '24px' }}>
<Btn onClick={onCancel} variant="secondary">Cancel</Btn>
<Btn onClick={() => onSave(s)}>Save</Btn>
</div>
</div>
</div>
);
}
/* ─── Calendar Section ─── */
function CalendarSection({ events, setEvents, images, onToast }: any) {
const [editing, setEditing] = useState<CalendarEvent | null>(null);
const [confirmDel, setConfirmDel] = useState<number | null>(null);
const save = async (ev: CalendarEvent) => {
try {
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) {
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'); }
};
const del = async (id: number) => {
await fetch(`/api/calendar_events/${id}`, { method: 'DELETE' });
setEvents((e: any) => e.filter((x: CalendarEvent) => x.id !== id));
onToast('Event deleted', 'success');
setConfirmDel(null);
};
return (
<div>
<SecHead title="📅 Calendar Events" count={events.length}
action={<Btn onClick={() => setEditing({ id: 0, date: '', title: '', type: 'default', color: '#D4A853', description: null, active: true })} size="sm">+ New Event</Btn>} />
{events.length === 0 ? <Empty icon="📅" title="No events" /> : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '16px' }}>
{events.map((ev: CalendarEvent) => (
<EntityCard key={ev.id} title={ev.title} desc={ev.description || ev.type} meta={`${ev.date}${ev.type}`}
actions={
<>
<Btn size="sm" variant="secondary" onClick={() => setEditing(ev)}>Edit</Btn>
<Btn size="sm" variant="danger" onClick={() => setConfirmDel(ev.id)}>Del</Btn>
</>
} />
))}
</div>
)}
{editing && <CalendarEditor event={editing} setEvent={setEditing} onSave={save} onCancel={() => setEditing(null)} />}
{confirmDel !== null && <Confirm message="Delete this event?" onCancel={() => setConfirmDel(null)} onConfirm={() => del(confirmDel)} />}
</div>
);
}
function CalendarEditor({ event, setEvent, onSave, onCancel }: any) {
const e = { ...event };
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: 460, width: '90%' }}>
<h3 style={{ margin: '0 0 20px', fontSize: '18px', fontWeight: 700, color: C.navy }}>{e.id ? 'Edit Event' : 'New Event'}</h3>
<div style={{ display: 'grid', gap: '14px' }}>
<I label="Title" value={e.title} onChange={(v: string) => setEvent({ ...e, title: v })} />
<I label="Date" type="date" value={e.date} onChange={(v: string) => setEvent({ ...e, date: v })} />
<S label="Type" value={e.type} onChange={(v: string) => setEvent({ ...e, type: v })}
options={[{ value: 'default', label: 'Default' }, { value: 'special', label: 'Special' }, { value: 'holiday', label: 'Holiday' }]} />
<I label="Color" value={e.color} onChange={(v: string) => setEvent({ ...e, color: v })} />
<TA label="Description" value={e.description || ''} onChange={(v: string) => setEvent({ ...e, description: v || null })} />
<Tgl label="Active" checked={e.active} onChange={(v: boolean) => setEvent({ ...e, active: v })} />
</div>
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end', marginTop: '24px' }}>
<Btn onClick={onCancel} variant="secondary">Cancel</Btn>
<Btn onClick={() => onSave(e)}>Save</Btn>
</div>
</div>
</div>
);
}
/* ─── Social Events Section ─── */
function SocialSection({ events, setEvents, images, onToast }: any) {
const [editing, setEditing] = useState<SocialEvent | null>(null);
const [confirmDel, setConfirmDel] = useState<number | null>(null);
const save = async (ev: SocialEvent) => {
try {
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) {
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'); }
};
const del = async (id: number) => {
await fetch(`/api/social_events/${id}`, { method: 'DELETE' });
setEvents((e: any) => e.filter((x: SocialEvent) => x.id !== id));
onToast('Event deleted', 'success');
setConfirmDel(null);
};
return (
<div>
<SecHead title="🎪 Social Events" count={events.length}
action={<Btn onClick={() => setEditing({ id: 0, name: '', description: '', price: null, date: null, photos: [], featured_photo: null, active: true, sort_order: events.length })} size="sm">+ New Event</Btn>} />
{events.length === 0 ? <Empty icon="🎪" title="No social events" /> : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '16px' }}>
{events.map((ev: SocialEvent) => (
<EntityCard key={ev.id} title={ev.name} desc={ev.description || ''} meta={`${ev.price || 'Free'}${ev.date ? ' • ' + ev.date : ''}`}
thumb={ev.featured_photo || ev.photos?.[0]}
photoUrls={ev.photos}
actions={
<>
<Btn size="sm" variant="secondary" onClick={() => setEditing(ev)}>Edit</Btn>
<Btn size="sm" variant="danger" onClick={() => setConfirmDel(ev.id)}>Del</Btn>
</>
} />
))}
</div>
)}
{editing && <SocialEditor event={editing} setEvent={setEditing} images={images} onSave={save} onCancel={() => setEditing(null)} />}
{confirmDel !== null && <Confirm message="Delete this event?" onCancel={() => setConfirmDel(null)} onConfirm={() => del(confirmDel)} />}
</div>
);
}
function SocialEditor({ event, setEvent, images, onSave, onCancel }: any) {
const [showPicker, setShowPicker] = useState(false);
const [localPhotos, setLocalPhotos] = useState<string[]>(event.photos || []);
const ev = { ...event, photos: localPhotos };
const addPhotos = async (files: FileList) => {
const newPhotos: string[] = [];
for (const f of Array.from(files)) {
const b64 = await readFileBase64(f);
try {
const r = await fetch('/api/admin/images', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: f.name, data: b64, category: 'social', room_id: null, location_target: 'social' }),
});
if (r.ok) { const d = await r.json(); newPhotos.push(d.data); }
} catch { const b = await readFileBase64(f); newPhotos.push(b); }
}
setLocalPhotos(prev => [...prev, ...newPhotos]);
};
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' }}>
<h3 style={{ margin: '0 0 20px', fontSize: '18px', fontWeight: 700, color: C.navy }}>{ev.id ? 'Edit Event' : 'New Event'}</h3>
<div style={{ display: 'grid', gap: '14px' }}>
<I label="Name" value={ev.name} onChange={(v: string) => setEvent({ ...ev, name: v })} />
<TA label="Description" value={ev.description || ''} onChange={(v: string) => setEvent({ ...ev, description: v })} />
<I label="Price" value={ev.price || ''} onChange={(v: string) => setEvent({ ...ev, price: v || null })} />
<I label="Date" type="date" value={ev.date || ''} onChange={(v: string) => setEvent({ ...ev, date: v || null })} />
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<label style={{ fontSize: '13px', fontWeight: 600, color: C.text }}>Photos</label>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<Btn onClick={() => document.getElementById('social-photos')?.click()} size="sm" variant="secondary">+ Add Photos</Btn>
<input id="social-photos" type="file" accept="image/*" multiple style={{ display: 'none' }}
onChange={e => { addPhotos(e.target.files!); e.target.value = ''; }} />
</div>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
{localPhotos.map((p, i) => (
<div key={i} style={{ position: 'relative' }}>
<img src={p} alt="" style={{ width: 64, height: 48, objectFit: 'cover', borderRadius: 6, border: '1px solid #eee' }} />
<button onClick={() => setLocalPhotos(prev => prev.filter((_, j) => j !== i))}
style={{ position: 'absolute', top: -4, right: -4, width: 18, height: 18, borderRadius: '50%', background: C.danger, color: '#fff', border: 'none', cursor: 'pointer', fontSize: '11px', lineHeight: 18, padding: 0 }}
>×</button>
</div>
))}
</div>
</div>
<Tgl label="Active" checked={ev.active} onChange={(v: boolean) => setEvent({ ...ev, active: v })} />
</div>
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end', marginTop: '24px' }}>
<Btn onClick={onCancel} variant="secondary">Cancel</Btn>
<Btn onClick={() => onSave(ev)}>Save</Btn>
</div>
</div>
</div>
);
}
/* ─── Rooms Section ─── */
function RoomsSection({ rooms, setRooms, images, onToast }: any) {
const [editing, setEditing] = useState<Room | null>(null);
const [confirmDel, setConfirmDel] = useState<number | null>(null);
const save = async (room: Room) => {
try {
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) {
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'); }
};
const del = async (id: number) => {
await fetch(`/api/rooms/${id}`, { method: 'DELETE' });
setRooms((r: any) => r.filter((x: Room) => x.id !== id));
onToast('Room deleted', 'success');
setConfirmDel(null);
};
return (
<div>
<SecHead title="🏨 Rooms" count={rooms.length}
action={<Btn onClick={() => setEditing({ id: 0, name: '', description: '', price: '', photos: [], featured_photo: null, active: true, sort_order: rooms.length })} size="sm">+ New Room</Btn>} />
{rooms.length === 0 ? <Empty icon="🏨" title="No rooms" /> : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '16px' }}>
{rooms.map((r: Room) => (
<EntityCard key={r.id} title={r.name} desc={r.description || ''} meta={r.price}
thumb={r.featured_photo || r.photos?.[0]}
photoUrls={r.photos}
actions={
<>
<Btn size="sm" variant="secondary" onClick={() => setEditing(r)}>Edit</Btn>
<Btn size="sm" variant="danger" onClick={() => setConfirmDel(r.id)}>Del</Btn>
</>
} />
))}
</div>
)}
{editing && <RoomEditor room={editing} setRoom={setEditing} images={images} onSave={save} onCancel={() => setEditing(null)} />}
{confirmDel !== null && <Confirm message="Delete this room?" onCancel={() => setConfirmDel(null)} onConfirm={() => del(confirmDel)} />}
</div>
);
}
function RoomEditor({ room, setRoom, images, onSave, onCancel }: any) {
const [showPicker, setShowPicker] = useState(false);
const [localPhotos, setLocalPhotos] = useState<string[]>(room.photos || []);
const r = { ...room, photos: localPhotos };
const addPhotos = async (files: FileList) => {
const newPhotos: string[] = [];
for (const f of Array.from(files)) {
const b64 = await readFileBase64(f);
try {
const res = await fetch('/api/admin/images', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: f.name, data: b64, category: 'rooms', room_id: r.id || null, location_target: `room-${r.id || 'new'}` }),
});
if (res.ok) { const d = await res.json(); newPhotos.push(d.data); }
} catch { const b = await readFileBase64(f); newPhotos.push(b); }
}
setLocalPhotos(prev => [...prev, ...newPhotos]);
};
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' }}>
<h3 style={{ margin: '0 0 20px', fontSize: '18px', fontWeight: 700, color: C.navy }}>{r.id ? 'Edit Room' : 'New Room'}</h3>
<div style={{ display: 'grid', gap: '14px' }}>
<I label="Name" value={r.name} onChange={(v: string) => setRoom({ ...r, name: v })} />
<TA label="Description" value={r.description || ''} onChange={(v: string) => setRoom({ ...r, description: v })} />
<I label="Price" value={r.price} onChange={(v: string) => setRoom({ ...r, price: v })} />
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<label style={{ fontSize: '13px', fontWeight: 600, color: C.text }}>Photos</label>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<Btn onClick={() => document.getElementById(`room-photos-${r.id}`)?.click()} size="sm" variant="secondary">+ Add Photos</Btn>
<input id={`room-photos-${r.id}`} type="file" accept="image/*" multiple style={{ display: 'none' }}
onChange={e => { addPhotos(e.target.files!); e.target.value = ''; }} />
</div>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
{localPhotos.map((p, i) => (
<div key={i} style={{ position: 'relative' }}>
<img src={p} alt="" style={{ width: 64, height: 48, objectFit: 'cover', borderRadius: 6, border: '1px solid #eee' }} />
<button onClick={() => setLocalPhotos(prev => prev.filter((_, j) => j !== i))}
style={{ position: 'absolute', top: -4, right: -4, width: 18, height: 18, borderRadius: '50%', background: C.danger, color: '#fff', border: 'none', cursor: 'pointer', fontSize: '11px', lineHeight: 18, padding: 0 }}>×</button>
</div>
))}
</div>
</div>
<Tgl label="Active" checked={r.active} onChange={(v: boolean) => setRoom({ ...r, active: v })} />
</div>
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end', marginTop: '24px' }}>
<Btn onClick={onCancel} variant="secondary">Cancel</Btn>
<Btn onClick={() => onSave(r)}>Save</Btn>
</div>
</div>
</div>
);
}
/* ─── Menu Section ─── */
function MenuSection({ items, setItems, images, onToast }: any) {
const [editing, setEditing] = useState<MenuItem | null>(null);
const [confirmDel, setConfirmDel] = useState<number | null>(null);
const [dragIdx, setDragIdx] = useState<number | null>(null);
const [overIdx, setOverIdx] = useState<number | null>(null);
const save = async (item: MenuItem) => {
if (!item.category || !item.name || !item.price) {
onToast('Category, name, and price are required', 'error');
return;
}
try {
const url = item.id ? `/api/menu/${item.id}` : '/api/menu/';
const res = await fetch(url, {
method: item.id ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item),
});
if (res.ok) {
const data = await res.json();
setEditing(null);
if (item.id) {
setItems((i: any) => i.map((x: any) => x.id === item.id ? data.data : x));
} else {
setItems((i: any) => [...i, data.data]);
}
onToast('Menu item saved!', 'success');
} else {
const err = await res.json().catch(() => ({}));
onToast(err.error || 'Failed to save menu item', 'error');
}
} catch { onToast('Error', 'error'); }
};
const del = async (id: number) => {
await fetch(`/api/menu/${id}`, { method: 'DELETE' });
setItems((i: any) => i.filter((x: any) => x.id !== id));
onToast('Item deleted', 'success');
setConfirmDel(null);
};
const toggleVisible = async (item: MenuItem) => {
const updated = { ...item, active: !item.active };
try {
await fetch(`/api/menu/${item.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...item, active: updated.active }),
});
setItems((i: any) => i.map((x: any) => x.id === item.id ? updated : x));
} catch { onToast('Error', 'error'); }
};
// ─── Drag-and-drop reordering ───
const handleDragStart = (idx: number) => { setDragIdx(idx); };
const handleDragOver = (e: any, idx: number) => {
e.preventDefault();
if (dragIdx !== null && dragIdx !== idx) setOverIdx(idx);
};
const handleDragEnd = () => {
if (dragIdx !== null && overIdx !== null && dragIdx !== overIdx) {
const reordered = [...items];
const [moved] = reordered.splice(dragIdx, 1);
reordered.splice(overIdx, 0, moved);
setItems(reordered);
setItems(reordered);
}
setDragIdx(null);
setOverIdx(null);
};
const saveOrder = async () => {
try {
await fetch('/api/menu/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order: items.map((it: MenuItem, i: number) => ({ id: it.id, sort_order: i })) }),
});
onToast('Order saved!', 'success');
} catch { onToast('Error reordering', 'error'); }
};
const sorted = [...items].sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));
return (
<div>
<SecHead title="🍽️ Menu" count={items.length}
action={<Btn onClick={() => setEditing({ id: 0, category: '', name: '', description: '', price: '', is_daily_special: false, active: true, sort_order: items.length })} size="sm">+ New Item</Btn>} />
{sorted.length === 0 ? <Empty icon="🍽️" title="No menu items" /> : (
<div style={{ background: C.card, borderRadius: 16, overflow: 'hidden', boxShadow: C.shadow }}>
{/* Drag handle instruction */}
<div style={{ padding: '8px 16px', background: C.goldLight, fontSize: '12px', color: C.textLight, display: 'flex', alignItems: 'center', gap: '8px' }}>
<span></span> Drag items to reorder · Toggle visibility to hide from the public site
</div>
{sorted.map((item: MenuItem, idx: number) => {
const isDragging = dragIdx === idx;
const isOver = overIdx === idx && dragIdx !== idx;
return (
<div
key={item.id}
draggable
onDragStart={(e: any) => { e.dataTransfer!.effectAllowed = 'move'; handleDragStart(idx); }}
onDragOver={(e) => handleDragOver(e, idx)}
onDragEnd={handleDragEnd}
onDragEnter={() => setOverIdx(idx)}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px 16px',
border: isOver ? `2px dashed ${C.gold}` : idx === 0 ? 'none' : `1px solid ${C.border}`,
borderTop: isDragging ? `2px dashed ${C.gold}` : 'none',
background: isDragging ? 'rgba(212,168,83,0.06)' : isOver ? C.goldLight : idx % 2 === 0 ? '#fff' : '#FAFAFA',
opacity: isDragging ? 0.5 : 1,
cursor: 'grab',
transition: 'all 150ms ease',
borderRadius: idx === 0 ? '0' : '0',
}}
onMouseEnter={(e) => {
if (!isDragging) e.currentTarget.style.background = C.goldLight;
}}
onMouseLeave={(e) => {
if (!isDragging && !isOver) e.currentTarget.style.background = idx % 2 === 0 ? '#fff' : '#FAFAFA';
}}
>
{/* Drag handle */}
<span style={{ fontSize: '18px', color: C.textLight, userSelect: 'none', flexShrink: 0, cursor: 'grab' }}
onMouseDown={() => {}}></span>
{/* Position number */}
<span style={{ fontSize: '13px', fontWeight: 700, color: C.textLight, width: '22px', textAlign: 'center', flexShrink: 0 }}>
{idx + 1}
</span>
{/* Visible/invisible toggle */}
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer', flexShrink: 0 }}
onClick={(e) => { e.stopPropagation(); toggleVisible(item); }}>
<div style={{
width: '42px', height: '24px', borderRadius: '12px', position: 'relative',
background: item.active ? C.success : '#ccc',
transition: 'background 200ms',
flexShrink: 0,
}}>
<div style={{
width: '20px', height: '20px', borderRadius: '50%', background: '#fff',
position: 'absolute', top: '2px',
left: item.active ? '20px' : '2px',
transition: 'left 200ms',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</div>
<span style={{ fontSize: '12px', color: item.active ? C.success : C.textLight, fontWeight: 600, minWidth: '24px' }}>
{item.active ? 'On' : 'Off'}
</span>
</label>
{/* Category badge */}
<span style={{
fontSize: '11px', fontWeight: 700, padding: '3px 8px', borderRadius: 6,
background: C.navy, color: '#fff', flexShrink: 0,
}}>{item.category}</span>
{/* Name & Description */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, color: C.navy, fontSize: '14px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{item.name}
</div>
{item.description && (
<div style={{ fontSize: '12px', color: C.textLight, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{item.description}
</div>
)}
</div>
{/* Price */}
<span style={{ fontWeight: 700, color: C.warm, fontSize: '14px', flexShrink: 0 }}>
{item.price}
</span>
{/* Actions */}
<div style={{ display: 'flex', gap: '6px', flexShrink: 0 }}>
<Btn size="sm" variant="secondary" onClick={() => setEditing(item)} style={{ fontSize: '11px', padding: '3px 8px' }}></Btn>
<Btn size="sm" variant="danger" onClick={() => setConfirmDel(item.id)} style={{ fontSize: '11px', padding: '3px 8px' }}>🗑</Btn>
</div>
</div>
);
})}
{/* Save order button */}
<div style={{ padding: '12px 16px', borderTop: `1px solid ${C.border}`, display: 'flex', justifyContent: 'flex-end' }}>
<Btn onClick={saveOrder} size="sm">💾 Save Order</Btn>
</div>
</div>
)}
{editing && <MenuEditor item={editing} setItem={setEditing} onSave={save} onCancel={() => setEditing(null)} />}
{confirmDel !== null && <Confirm message="Delete this menu item?" onCancel={() => setConfirmDel(null)} onConfirm={() => del(confirmDel)} />}
</div>
);
}
function MenuEditor({ item, setItem, onSave, onCancel }: any) {
const i = { ...item };
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: 460, width: '90%' }}>
<h3 style={{ margin: '0 0 20px', fontSize: '18px', fontWeight: 700, color: C.navy }}>{i.id ? 'Edit Item' : 'New Item'}</h3>
<div style={{ display: 'grid', gap: '14px' }}>
<I label="Category" value={i.category} onChange={(v: string) => setItem({ ...i, category: v })} />
<I label="Name" value={i.name} onChange={(v: string) => setItem({ ...i, name: v })} />
<TA label="Description" value={i.description || ''} onChange={(v: string) => setItem({ ...i, description: v })} />
<I label="Price" value={i.price} onChange={(v: string) => setItem({ ...i, price: v })} />
<Tgl label="Daily Special" checked={i.is_daily_special} onChange={(v: boolean) => setItem({ ...i, is_daily_special: v })} />
<Tgl label="Active" checked={i.active} onChange={(v: boolean) => setItem({ ...i, active: v })} />
</div>
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end', marginTop: '24px' }}>
<Btn onClick={onCancel} variant="secondary">Cancel</Btn>
<Btn onClick={() => onSave(i)}>Save</Btn>
</div>
</div>
</div>
);
}
/* ─── Places Section ─── */
function PlacesSection({ places, setPlaces, images, onToast }: any) {
const [editing, setEditing] = useState<Place | null>(null);
const [confirmDel, setConfirmDel] = useState<number | null>(null);
const save = async (place: Place) => {
try {
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) {
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'); }
};
const del = async (id: number) => {
await fetch(`/api/places/${id}`, { method: 'DELETE' });
setPlaces((p: any) => p.filter((x: Place) => x.id !== id));
onToast('Place deleted', 'success');
setConfirmDel(null);
};
return (
<div>
<SecHead title="🗺️ Landscape Places" count={places.length}
action={<Btn onClick={() => setEditing({ id: 0, name: '', description: '', photos: [], featured_photo: null, distance_km: '', visit_time: '', suggestion: '', active: true, sort_order: places.length })} size="sm">+ New Place</Btn>} />
{places.length === 0 ? <Empty icon="🗺️" title="No places" /> : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '16px' }}>
{places.map((p: Place) => (
<EntityCard key={p.id} title={p.name} desc={p.description || ''} meta={`${p.distance_km || ''} ${p.visit_time ? '• ' + p.visit_time : ''}`}
thumb={p.featured_photo || p.photos?.[0]}
photoUrls={p.photos}
actions={
<>
<Btn size="sm" variant="secondary" onClick={() => setEditing(p)}>Edit</Btn>
<Btn size="sm" variant="danger" onClick={() => setConfirmDel(p.id)}>Del</Btn>
</>
} />
))}
</div>
)}
{editing && <PlaceEditor place={editing} setPlace={setEditing} images={images} onSave={save} onCancel={() => setEditing(null)} />}
{confirmDel !== null && <Confirm message="Delete this place?" onCancel={() => setConfirmDel(null)} onConfirm={() => del(confirmDel)} />}
</div>
);
}
function PlaceEditor({ place, setPlace, images, onSave, onCancel }: any) {
const [localPhotos, setLocalPhotos] = useState<string[]>(place.photos || []);
const p = { ...place, photos: localPhotos };
const addPhotos = async (files: FileList) => {
const newPhotos: string[] = [];
for (const f of Array.from(files)) {
const b64 = await readFileBase64(f);
try {
const res = await fetch('/api/admin/images', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: f.name, data: b64, category: 'landscape', room_id: null, location_target: `place-${p.id || 'new'}` }),
});
if (res.ok) { const d = await res.json(); newPhotos.push(d.data); }
} catch { const b = await readFileBase64(f); newPhotos.push(b); }
}
setLocalPhotos(prev => [...prev, ...newPhotos]);
};
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' }}>
<h3 style={{ margin: '0 0 20px', fontSize: '18px', fontWeight: 700, color: C.navy }}>{p.id ? 'Edit Place' : 'New Place'}</h3>
<div style={{ display: 'grid', gap: '14px' }}>
<I label="Name" value={p.name} onChange={(v: string) => setPlace({ ...p, name: v })} />
<TA label="Description" value={p.description || ''} onChange={(v: string) => setPlace({ ...p, description: v })} />
<I label="Distance (km)" value={p.distance_km} onChange={(v: string) => setPlace({ ...p, distance_km: v })} />
<I label="Visit Time" value={p.visit_time} onChange={(v: string) => setPlace({ ...p, visit_time: v })} />
<TA label="Suggestion" value={p.suggestion || ''} onChange={(v: string) => setPlace({ ...p, suggestion: v })} />
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<label style={{ fontSize: '13px', fontWeight: 600, color: C.text }}>Photos</label>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<Btn onClick={() => document.getElementById(`place-photos-${p.id}`)?.click()} size="sm" variant="secondary">+ Add Photos</Btn>
<input id={`place-photos-${p.id}`} type="file" accept="image/*" multiple style={{ display: 'none' }}
onChange={e => { addPhotos(e.target.files!); e.target.value = ''; }} />
</div>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
{localPhotos.map((ph, i) => (
<div key={i} style={{ position: 'relative' }}>
<img src={ph} alt="" style={{ width: 64, height: 48, objectFit: 'cover', borderRadius: 6, border: '1px solid #eee' }} />
<button onClick={() => setLocalPhotos(prev => prev.filter((_, j) => j !== i))}
style={{ position: 'absolute', top: -4, right: -4, width: 18, height: 18, borderRadius: '50%', background: C.danger, color: '#fff', border: 'none', cursor: 'pointer', fontSize: '11px', lineHeight: 18, padding: 0 }}>×</button>
</div>
))}
</div>
</div>
<Tgl label="Active" checked={p.active} onChange={(v: boolean) => setPlace({ ...p, active: v })} />
</div>
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end', marginTop: '24px' }}>
<Btn onClick={onCancel} variant="secondary">Cancel</Btn>
<Btn onClick={() => onSave(p)}>Save</Btn>
</div>
</div>
</div>
);
}
/* ─── Reviews Section ─── */
function ReviewsSection({ reviews, setReviews, onToast }: any) {
const approve = async (id: number) => {
try {
await fetch(`/api/reviews/${id}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approved: true }),
});
setReviews((r: any) => r.map((x: Review) => x.id === id ? { ...x, approved: true } : x));
onToast('Review approved!', 'success');
} catch { onToast('Error', 'error'); }
};
const reject = async (id: number) => {
try {
await fetch(`/api/reviews/${id}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approved: false }),
});
setReviews((r: any) => r.map((x: Review) => x.id === id ? { ...x, approved: false } : x));
onToast('Review hidden', 'success');
} catch { onToast('Error', 'error'); }
};
return (
<div>
<SecHead title="⭐ Reviews" count={reviews.length} />
{reviews.length === 0 ? <Empty icon="⭐" title="No reviews" /> : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '16px' }}>
{reviews.map((r: Review) => (
<div key={r.id} style={{ background: C.card, borderRadius: 16, padding: '16px 20px', boxShadow: C.shadow, border: `1px solid ${C.border}` }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<span style={{ fontWeight: 700, color: C.navy }}>{r.author_name}</span>
<Badge color={r.approved ? C.success : '#FF9800'}>{r.approved ? 'Approved' : 'Pending'}</Badge>
</div>
<div style={{ fontSize: '14px', color: '#FFB400', marginBottom: '8px', letterSpacing: 2 }}>
{'★'.repeat(r.rating)}{'☆'.repeat(5 - r.rating)}
</div>
<p style={{ margin: '0 0 12px', fontSize: '13px', color: C.text, lineHeight: 1.5 }}>{r.text}</p>
<div style={{ display: 'flex', gap: '8px' }}>
{!r.approved && <Btn size="sm" variant="secondary" onClick={() => approve(r.id)}>Approve</Btn>}
{r.approved && <Btn size="sm" variant="secondary" onClick={() => reject(r.id)}>Hide</Btn>}
</div>
</div>
))}
</div>
)}
</div>
);
}
/* ─── Brand Section ─── */
function BrandSection({ images, onToast }: { images: Image[]; onToast: (m: string, t: string) => void }) {
const [config, setConfig] = useState<FooterConfig>({
logo: '', favicon: '', tagline: 'Tu refugio en la naturaleza',
facebook_url: '', instagram_url: '', whatsapp_url: '',
address: '', phone: '', email: '',
});
const handleUpload = async (key: keyof FooterConfig) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
const b64 = await readFileBase64(file);
try {
const res = await fetch('/api/admin/images', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: file.name, data: b64, category: 'other', room_id: null, location_target: key }),
});
if (res.ok) {
const d = await res.json();
setConfig(prev => ({ ...prev, [key]: d.data }));
onToast('Image uploaded!', 'success');
}
} catch { onToast('Upload failed', 'error'); }
};
input.click();
};
return (
<div>
<SecHead title="🎨 Brand & Identity" />
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: '20px' }}>
{(['logo', 'favicon'] as (keyof FooterConfig)[]).map(key => (
<div key={key} style={{ background: C.card, borderRadius: 16, padding: '20px', boxShadow: C.shadow, textAlign: 'center', border: `1px solid ${C.border}` }}>
<div style={{ fontSize: '13px', fontWeight: 600, color: C.text, marginBottom: '12px', textTransform: 'capitalize' }}>{key}</div>
{config[key] ? (
<img src={config[key] as string} alt={key} style={{ width: 120, height: 80, objectFit: 'contain', borderRadius: 8, marginBottom: '12px' }} />
) : (
<div style={{ width: 120, height: 80, background: '#EEE', borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#BBB', margin: '0 auto 12px' }}>
No {key}
</div>
)}
<Btn onClick={() => handleUpload(key)} size="sm" variant="secondary">Upload</Btn>
</div>
))}
<div style={{ background: C.card, borderRadius: 16, padding: '20px', boxShadow: C.shadow, border: `1px solid ${C.border}`, gridColumn: 'span 2' }}>
<div style={{ fontSize: '13px', fontWeight: 600, color: C.text, marginBottom: '12px' }}>Tagline</div>
<I value={config.tagline} onChange={(v: string) => setConfig(prev => ({ ...prev, tagline: v }))} placeholder="Your hotel tagline" />
</div>
</div>
</div>
);
}
/* ─── Dashboard (stats cards) ─── */
function Dashboard({ rooms, slides, events, reviews, places }: any) {
return (
<div>
<h2 style={{ margin: '0 0 20px', fontSize: '22px', fontWeight: 700, color: C.navy }}>Dashboard</h2>
<div style={{ display: 'flex', gap: '14px', flexWrap: 'wrap', marginBottom: '24px' }}>
<StatCard icon="🏨" value={rooms?.length || 0} label="Rooms" />
<StatCard icon="🎞️" value={slides?.length || 0} label="Slides" />
<StatCard icon="📅" value={events?.length || 0} label="Events" />
<StatCard icon="🗺️" value={places?.length || 0} label="Places" />
<StatCard icon="⭐" value={reviews?.filter((r: Review) => r.approved)?.length || 0} label="Approved Reviews" />
</div>
</div>
);
}
/* ─── Users Section ─── */
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" 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>
);
}
/* ─── Weather Section ─── */
function WeatherSection({ onToast }: { onToast: (msg: string, type: string) => void }) {
const [enabled, setEnabled] = useState(true);
const [cities, setCities] = useState<{ name: string; lat: string; lon: string }[]>([]);
const [newCity, setNewCity] = useState({ name: '', lat: '', lon: '' });
const [loading, setLoading] = useState(true);
// Load weather config
useEffect(() => {
const loadConfig = async () => {
try {
const res = await fetch('/api/site-assets?key=weather_config');
if (res.ok) {
const data = await res.json();
if (data.data) {
const config = JSON.parse(data.data);
setEnabled(config.enabled ?? true);
setCities(config.cities ?? []);
}
}
} catch (err) {
console.error('Failed to load weather config:', err);
} finally {
setLoading(false);
}
};
loadConfig();
}, []);
const saveConfig = async () => {
try {
const config = { enabled, cities };
const res = await fetch('/api/site-assets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: 'weather_config', value: JSON.stringify(config) }),
});
if (res.ok) {
onToast('Weather settings saved', 'success');
} else {
throw new Error('Failed to save');
}
} catch (err) {
onToast('Error saving weather settings', 'error');
}
};
const addCity = () => {
if (newCity.name && newCity.lat && newCity.lon) {
setCities([...cities, newCity]);
setNewCity({ name: '', lat: '', lon: '' });
}
};
const removeCity = (index: number) => {
setCities(cities.filter((_, i) => i !== index));
};
if (loading) {
return (
<div>
<SecHead title="🌤️ Weather Widget" />
<div style={{ textAlign: 'center', padding: '40px', color: C.textLight }}>
Loading...
</div>
</div>
);
}
return (
<div>
<SecHead title="🌤️ Weather Widget" />
<div style={{ background: C.card, borderRadius: 12, padding: '24px', marginBottom: '24px', border: `1px solid ${C.border}` }}>
<p style={{ color: C.text, marginBottom: '24px' }}>
Configure the weather widget displayed on the homepage
</p>
<div style={{ marginBottom: '24px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
style={{ width: '18px', height: '18px' }}
/>
<span style={{ fontWeight: 500, color: C.text }}>Enable Weather Widget</span>
</label>
</div>
<h3 style={{ margin: '0 0 16px', color: C.text, fontSize: '16px' }}>Cities</h3>
<div style={{ marginBottom: '16px' }}>
{cities.map((city, index) => (
<div key={index} style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px', padding: '8px', background: C.bg, borderRadius: '8px' }}>
<span style={{ flex: 1, color: C.text }}>{city.name} ({city.lat}, {city.lon})</span>
<button
onClick={() => removeCity(index)}
style={{
background: C.danger,
color: 'white',
border: 'none',
borderRadius: '4px',
padding: '4px 8px',
cursor: 'pointer',
fontSize: '12px'
}}
>
Remove
</button>
</div>
))}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', padding: '16px', background: C.bg, borderRadius: '8px' }}>
<h4 style={{ margin: '0 0 8px', color: C.text, fontSize: '14px' }}>Add City</h4>
<I label="City Name" value={newCity.name} onChange={(v: string) => setNewCity({...newCity, name: v})} />
<div style={{ display: 'flex', gap: '12px' }}>
<I label="Latitude" value={newCity.lat} onChange={(v: string) => setNewCity({...newCity, lat: v})} />
<I label="Longitude" value={newCity.lon} onChange={(v: string) => setNewCity({...newCity, lon: v})} />
</div>
<button
onClick={addCity}
style={{
alignSelf: 'flex-start',
background: C.gold,
color: 'white',
border: 'none',
borderRadius: '6px',
padding: '8px 16px',
cursor: 'pointer',
fontWeight: 500
}}
>
Add City
</button>
</div>
<div style={{ marginTop: '24px' }}>
<button
onClick={saveConfig}
style={{
background: C.gold,
color: 'white',
border: 'none',
borderRadius: '6px',
padding: '12px 24px',
cursor: 'pointer',
fontWeight: 600,
fontSize: '14px'
}}
>
Save Settings
</button>
</div>
</div>
</div>
);
}
/* ═══════════════════════════════════════════════════
MAIN PAGE
═══════════════════════════════════════════════════ */
export default function AdminPage() {
const [activeSection, setActiveSection] = useState('dashboard');
const [sidebarOpen, setSidebarOpen] = useState(false);
const [toasts, setToasts] = useState<{ id: number; message: string; type: string }[]>([]);
const [authChecked, setAuthChecked] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [slides, setSlides] = useState<Slide[]>([]);
const [rooms, setRooms] = useState<Room[]>([]);
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [socialEvents, setSocialEvents] = useState<SocialEvent[]>([]);
const [menuItems, setMenuItems] = useState<MenuItem[]>([]);
const [places, setPlaces] = useState<Place[]>([]);
const [reviews, setReviews] = useState<Review[]>([]);
const [images, setImages] = useState<Image[]>([]);
const [imageFilter, setImageFilter] = useState('all');
const [galleryLoading, setGalleryLoading] = useState(false);
const toastId = useRef(0);
const showToast = useCallback((message: string, type: string) => {
const id = ++toastId.current;
setToasts(prev => [...prev, { id, message, type }]);
}, []);
const removeToast = useCallback((id: number) => {
setToasts(prev => prev.filter(t => t.id !== id));
}, []);
/* ── Auth check ── */
useEffect(() => {
fetch('/api/auth/me', { credentials: 'include' })
.then(r => r.json())
.then(data => {
if (data.authenticated) setIsAuthenticated(true);
setAuthChecked(true);
})
.catch(() => setAuthChecked(true));
}, []);
/* ── 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 () => {
try {
const [slidesRes, roomsRes, calRes, socialRes, menuRes, placesRes, reviewsRes, imagesRes] = await Promise.all([
fetch('/api/slides'),
fetch('/api/rooms'),
fetch('/api/calendar_events'),
fetch('/api/social_events/public'),
fetch('/api/admin/menu'),
fetch('/api/places'),
fetch('/api/reviews'),
fetch('/api/admin/images').catch(() => null),
]);
if (slidesRes.ok) { const d = await slidesRes.json(); setSlides(Array.isArray(d) ? d : (d.data || [])); }
if (roomsRes.ok) { const d = await roomsRes.json(); setRooms(Array.isArray(d) ? d : (d.data || [])); }
if (calRes.ok) { const d = await calRes.json(); setEvents(Array.isArray(d) ? d : (d.data || [])); }
if (socialRes.ok) { const d = await socialRes.json(); setSocialEvents(Array.isArray(d) ? d : (d.data || [])); }
if (menuRes.ok) { const d = await menuRes.json(); setMenuItems(Array.isArray(d) ? d : (d.data || [])); }
if (placesRes.ok) { const d = await placesRes.json(); setPlaces(Array.isArray(d) ? d : (d.data || [])); }
if (reviewsRes.ok) { const d = await reviewsRes.json(); setReviews(Array.isArray(d) ? d : (d.data || [])); }
if (imagesRes?.ok) { const d = await imagesRes.json(); setImages(Array.isArray(d) ? d : (d.data || d.images || [])); }
} catch (err) { console.error('Failed to load admin data:', err); }
};
fetchAll();
}, [isAuthenticated]);
const deleteImage = async (id: number) => {
try {
await fetch(`/api/admin/images/${id}`, { method: 'DELETE' });
setImages(prev => prev.filter(img => img.id !== id));
showToast('Image deleted', 'success');
} catch { showToast('Error deleting image', 'error'); }
};
const renderSection = () => {
switch (activeSection) {
case 'dashboard': return <Dashboard rooms={rooms} slides={slides} events={events} reviews={reviews} places={places} />;
case 'brand': return <BrandSection images={images} onToast={showToast} />;
case 'gallery': return (
<div>
<SecHead title="🖼️ Image Gallery" count={images.length} />
<ImageGallery images={images} onDelete={deleteImage} />
</div>
);
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 onToast={showToast} />;
case 'weather': return <WeatherSection onToast={showToast} />;
default: return <Dashboard rooms={rooms} slides={slides} events={events} reviews={reviews} places={places} />;
}
};
return (
<div style={{ minHeight: '100vh', background: C.bg, display: 'flex' }}>
{/* Toasts */}
{toasts.map(t => <Toast key={t.id} message={t.message} type={t.type} onClose={() => removeToast(t.id)} />)}
{/* Auth loading */}
{!authChecked && (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '32px', marginBottom: '16px' }}></div>
<div style={{ fontSize: '16px', color: C.text }}>Checking authentication...</div>
</div>
</div>
)}
{/* Not authenticated - show login prompt */}
{authChecked && !isAuthenticated && (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ textAlign: 'center', maxWidth: 400, padding: '40px', background: '#fff', borderRadius: 16, boxShadow: C.shadow }}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🔒</div>
<h2 style={{ margin: '0 0 12px', fontSize: '24px', color: C.navy }}>Admin Access Required</h2>
<p style={{ margin: '0 0 24px', fontSize: '14px', color: C.textLight }}>
Please log in to access the admin panel.
</p>
<a href="/login" style={{ display: 'inline-block', padding: '12px 32px', background: C.gold, color: '#fff', borderRadius: 8, textDecoration: 'none', fontWeight: 600, fontSize: '15px' }}>
Go to Login
</a>
</div>
</div>
)}
{/* Authenticated - show admin panel */}
{authChecked && isAuthenticated && (
<>
{/* Mobile sidebar toggle */}
<div style={{ display: 'none' }} /> {/* placeholder */}
{/* Sidebar */}
<aside style={{
width: 260, background: C.navy, height: '100vh', position: 'sticky', top: 0, overflowY: 'auto',
display: 'flex', flexDirection: 'column', transition: 'transform 300ms',
borderRight: '1px solid rgba(255,255,255,0.06)',
}}>
<div style={{ padding: '24px 20px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<h1 style={{ margin: 0, fontSize: '18px', fontWeight: 700, color: C.gold }}>
🏨 Hotel La Huasca
</h1>
<p style={{ margin: '4px 0 0', fontSize: '11px', color: 'rgba(255,255,255,0.4)' }}>Admin Portal</p>
</div>
<nav style={{ padding: '16px 12px', flex: 1 }}>
{NAV.map(n => (
<button key={n.id} onClick={() => { setActiveSection(n.id); setSidebarOpen(false); }}
style={{ width: '100%', display: 'flex', alignItems: 'center', gap: '12px', padding: '10px 14px', marginBottom: '4px',
borderRadius: 10, border: 'none', cursor: 'pointer', fontSize: '14px', fontWeight: 500,
background: activeSection === n.id ? C.goldLight : 'transparent',
color: activeSection === n.id ? C.gold : 'rgba(255,255,255,0.6)',
transition: 'all 200ms', textAlign: 'left' }}
onMouseEnter={e => { if (activeSection !== n.id) { e.currentTarget.style.background = 'rgba(255,255,255,0.06)'; e.currentTarget.style.color = '#fff'; } }}
onMouseLeave={e => { if (activeSection !== n.id) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'rgba(255,255,255,0.6)'; } }}>
<span style={{ fontSize: '18px' }}>{n.icon}</span>
{n.label}
</button>
))}
</nav>
<div style={{ padding: '16px 20px', borderTop: '1px solid rgba(255,255,255,0.06)', fontSize: '11px', color: 'rgba(255,255,255,0.3)' }}>
v1.0.0
</div>
</aside>
{/* Main content */}
<main style={{ flex: 1, padding: '32px 40px', overflowY: 'auto', maxHeight: '100vh' }}>
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
{renderSection()}
</div>
</main>
</>
)}
</div>
);
}