2159 lines
106 KiB
TypeScript
2159 lines
106 KiB
TypeScript
'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: 'trips', label: 'Trips', icon: '✈️' },
|
||
{ id: 'messages', label: 'Messages', 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 fileInputRef = useRef<HTMLInputElement>(null);
|
||
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>
|
||
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={(e) => { if (e.target.files?.length) handleUpload(e.target.files); e.target.value = ''; }} />
|
||
<Btn size="sm" variant="secondary" disabled={uploading} onClick={() => fileInputRef.current?.click()}>{uploading ? 'Uploading...' : 'Upload from computer'}</Btn>
|
||
{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.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.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>
|
||
);
|
||
}
|
||
|
||
/* ─── Trips Section ─── */
|
||
type Trip = { id: number; user_id: number; username: string; room_id: number | null; room_name: string | null; check_in: string; check_out: string; notes: string | null; status: string };
|
||
|
||
function TripsSection({ onToast }: { onToast: (msg: string, type: string) => void }) {
|
||
const [trips, setTrips] = useState<Trip[]>([]);
|
||
const [users, setUsers] = useState<{id: number; username: string}[]>([]);
|
||
const [rooms, setRooms] = useState<{id: number; name: string}[]>([]);
|
||
const [editing, setEditing] = useState<Trip | null>(null);
|
||
const [creating, setCreating] = useState(false);
|
||
|
||
useEffect(() => {
|
||
const load = async () => {
|
||
const [tripsRes, usersRes, roomsRes] = await Promise.all([
|
||
fetch('/api/admin/trips'),
|
||
fetch('/api/admin/users'),
|
||
fetch('/api/rooms'),
|
||
]);
|
||
if (tripsRes.ok) { const d = await tripsRes.json(); setTrips(d.trips || []); }
|
||
if (usersRes.ok) { const d = await usersRes.json(); setUsers(d.users || []); }
|
||
if (roomsRes.ok) { const d = await roomsRes.json(); setRooms(Array.isArray(d) ? d : (d.data || [])); }
|
||
};
|
||
load();
|
||
}, []);
|
||
|
||
const save = async (trip: Trip) => {
|
||
try {
|
||
const method = trip.id ? 'PUT' : 'POST';
|
||
const res = await fetch('/api/admin/trips', {
|
||
method,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(trip),
|
||
});
|
||
if (res.ok) {
|
||
const d = await res.json();
|
||
if (trip.id) {
|
||
setTrips(prev => prev.map(t => t.id === trip.id ? d.trip : t));
|
||
} else {
|
||
setTrips(prev => [...prev, d.trip]);
|
||
}
|
||
setEditing(null);
|
||
setCreating(false);
|
||
onToast('Trip saved', 'success');
|
||
} else {
|
||
const err = await res.json();
|
||
onToast(err.error || 'Failed to save trip', 'error');
|
||
}
|
||
} catch { onToast('Error saving trip', 'error'); }
|
||
};
|
||
|
||
const del = async (id: number) => {
|
||
if (!confirm('Delete this trip?')) return;
|
||
try {
|
||
await fetch('/api/admin/trips', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
|
||
setTrips(prev => prev.filter(t => t.id !== id));
|
||
onToast('Trip deleted', 'success');
|
||
} catch { onToast('Error deleting trip', 'error'); }
|
||
};
|
||
|
||
const emptyTrip: Trip = { id: 0, user_id: 0, username: '', room_id: null, room_name: null, check_in: '', check_out: '', notes: '', status: 'confirmed' };
|
||
|
||
return (
|
||
<div>
|
||
<SecHead title="✈️ Trips" count={trips.length} onAdd={() => { setCreating(true); setEditing(emptyTrip); }} />
|
||
{creating && editing && !editing.id && (
|
||
<div style={{ background: '#fff', borderRadius: 12, padding: '20px', marginBottom: '20px', boxShadow: C.shadow }}>
|
||
<h3 style={{ margin: '0 0 16px', fontSize: '16px' }}>New Trip</h3>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '12px' }}>
|
||
<S label="User" value={editing.user_id} onChange={(v: string) => setEditing({ ...editing, user_id: Number(v) })} options={[{ value: 0, label: 'Select user' }, ...users.map(u => ({ value: u.id, label: u.username }))]} />
|
||
<S label="Room" value={editing.room_id || ''} onChange={(v: string) => setEditing({ ...editing, room_id: v ? Number(v) : null })} options={[{ value: '', label: 'No room' }, ...rooms.map(r => ({ value: r.id, label: r.name }))]} />
|
||
<I label="Check-in" type="date" value={editing.check_in} onChange={(v: string) => setEditing({ ...editing, check_in: v })} />
|
||
<I label="Check-out" type="date" value={editing.check_out} onChange={(v: string) => setEditing({ ...editing, check_out: v })} />
|
||
<S label="Status" value={editing.status} onChange={(v: string) => setEditing({ ...editing, status: v })} options={['pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled'].map(s => ({ value: s, label: s.replace('_', ' ') }))} />
|
||
</div>
|
||
<TA label="Notes" value={editing.notes || ''} onChange={(v: string) => setEditing({ ...editing, notes: v })} rows={2} />
|
||
<div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
|
||
<Btn onClick={() => save(editing)}>Save Trip</Btn>
|
||
<Btn variant="secondary" onClick={() => { setCreating(false); setEditing(null); }}>Cancel</Btn>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||
{trips.map(t => (
|
||
<div key={t.id} style={{ background: '#fff', borderRadius: 12, padding: '16px', boxShadow: C.shadow, display: 'flex', alignItems: 'center', gap: '16px', flexWrap: 'wrap' }}>
|
||
<div style={{ flex: '1 1 200px' }}>
|
||
<div style={{ fontWeight: 600, color: C.navy }}>{t.username || `User ${t.user_id}`}</div>
|
||
<div style={{ fontSize: '13px', color: C.textLight }}>{t.room_name || 'No room assigned'}</div>
|
||
</div>
|
||
<div style={{ fontSize: '13px', color: C.text }}>
|
||
<strong>In:</strong> {new Date(t.check_in).toLocaleDateString()} · <strong>Out:</strong> {new Date(t.check_out).toLocaleDateString()}
|
||
</div>
|
||
<Badge color={t.status === 'confirmed' ? C.success : t.status === 'checked_in' ? '#1976d2' : t.status === 'cancelled' ? C.danger : '#666'}>{t.status.replace('_', ' ')}</Badge>
|
||
<div style={{ display: 'flex', gap: '8px' }}>
|
||
<Btn size="sm" onClick={() => { setCreating(false); setEditing(t); }}>Edit</Btn>
|
||
<Btn size="sm" variant="danger" onClick={() => del(t.id)}>Delete</Btn>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{!creating && editing && editing.id && (
|
||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }} onClick={() => setEditing(null)}>
|
||
<div style={{ background: '#fff', borderRadius: 12, padding: '24px', maxWidth: '500px', width: '90%' }} onClick={e => e.stopPropagation()}>
|
||
<h3 style={{ margin: '0 0 16px' }}>Edit Trip</h3>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||
<S label="User" value={editing.user_id} onChange={(v: string) => setEditing({ ...editing, user_id: Number(v) })} options={[{ value: 0, label: 'Select user' }, ...users.map(u => ({ value: u.id, label: u.username }))]} />
|
||
<S label="Room" value={editing.room_id || ''} onChange={(v: string) => setEditing({ ...editing, room_id: v ? Number(v) : null })} options={[{ value: '', label: 'No room' }, ...rooms.map(r => ({ value: r.id, label: r.name }))]} />
|
||
<I label="Check-in" type="date" value={editing.check_in} onChange={(v: string) => setEditing({ ...editing, check_in: v })} />
|
||
<I label="Check-out" type="date" value={editing.check_out} onChange={(v: string) => setEditing({ ...editing, check_out: v })} />
|
||
<S label="Status" value={editing.status} onChange={(v: string) => setEditing({ ...editing, status: v })} options={['pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled'].map(s => ({ value: s, label: s.replace('_', ' ') }))} />
|
||
</div>
|
||
<TA label="Notes" value={editing.notes || ''} onChange={(v: string) => setEditing({ ...editing, notes: v })} rows={2} />
|
||
<div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
|
||
<Btn onClick={() => save(editing)}>Save</Btn>
|
||
<Btn variant="secondary" onClick={() => setEditing(null)}>Cancel</Btn>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─── Messages Section ─── */
|
||
type MsgThread = { user_id: number; username: string; message: string; created_at: string };
|
||
type Msg = { id: number; sender_role: string; message: string; created_at: string };
|
||
|
||
function MessagesSection({ onToast }: { onToast: (msg: string, type: string) => void }) {
|
||
const [threads, setThreads] = useState<MsgThread[]>([]);
|
||
const [selectedUser, setSelectedUser] = useState<number | null>(null);
|
||
const [messages, setMessages] = useState<Msg[]>([]);
|
||
const [reply, setReply] = useState('');
|
||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
const load = async () => {
|
||
const res = await fetch('/api/admin/messages');
|
||
if (res.ok) { const d = await res.json(); setThreads(d.threads || []); }
|
||
};
|
||
load();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (selectedUser) {
|
||
const load = async () => {
|
||
const res = await fetch(`/api/admin/messages?user_id=${selectedUser}`);
|
||
if (res.ok) { const d = await res.json(); setMessages(d.messages || []); }
|
||
};
|
||
load();
|
||
}
|
||
}, [selectedUser]);
|
||
|
||
useEffect(() => {
|
||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||
}, [messages]);
|
||
|
||
const sendReply = async () => {
|
||
if (!reply.trim() || !selectedUser) return;
|
||
try {
|
||
const res = await fetch('/api/admin/messages', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ user_id: selectedUser, message: reply.trim() }),
|
||
});
|
||
if (res.ok) {
|
||
const d = await res.json();
|
||
setMessages(prev => [...prev, d.message]);
|
||
setReply('');
|
||
onToast('Reply sent', 'success');
|
||
}
|
||
} catch { onToast('Error sending reply', 'error'); }
|
||
};
|
||
|
||
return (
|
||
<div style={{ display: 'grid', gridTemplateColumns: '300px 1fr', gap: '20px', minHeight: '400px' }}>
|
||
<div style={{ background: '#fff', borderRadius: 12, overflow: 'hidden', boxShadow: C.shadow }}>
|
||
<div style={{ padding: '16px', borderBottom: `1px solid ${C.border}`, fontWeight: 600, color: C.navy }}>Conversations</div>
|
||
<div style={{ maxHeight: '400px', overflowY: 'auto' }}>
|
||
{threads.length === 0 ? (
|
||
<div style={{ padding: '20px', textAlign: 'center', color: C.textLight }}>No conversations yet</div>
|
||
) : (
|
||
threads.map(t => (
|
||
<div key={t.user_id} onClick={() => setSelectedUser(t.user_id)} style={{ padding: '12px 16px', cursor: 'pointer', background: selectedUser === t.user_id ? C.goldLight : 'transparent', borderBottom: `1px solid ${C.border}` }}
|
||
onMouseEnter={e => { if (selectedUser !== t.user_id) (e.currentTarget.style as any).background = '#f5f5f5'; }}
|
||
onMouseLeave={e => { if (selectedUser !== t.user_id) (e.currentTarget.style as any).background = 'transparent'; }}>
|
||
<div style={{ fontWeight: 500, color: C.navy }}>{t.username}</div>
|
||
<div style={{ fontSize: '12px', color: C.textLight, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.message}</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div style={{ background: '#fff', borderRadius: 12, overflow: 'hidden', boxShadow: C.shadow, display: 'flex', flexDirection: 'column' }}>
|
||
{selectedUser ? (
|
||
<>
|
||
<div style={{ padding: '16px', borderBottom: `1px solid ${C.border}`, fontWeight: 600, color: C.navy }}>
|
||
{threads.find(t => t.user_id === selectedUser)?.username || `User ${selectedUser}`}
|
||
</div>
|
||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px', maxHeight: '300px' }}>
|
||
{messages.map(m => (
|
||
<div key={m.id} style={{ display: 'flex', justifyContent: m.sender_role === 'admin' ? 'flex-start' : 'flex-end', marginBottom: '12px' }}>
|
||
<div style={{ maxWidth: '70%', padding: '10px 14px', borderRadius: 12, background: m.sender_role === 'admin' ? C.gold : '#f0f0f0', color: m.sender_role === 'admin' ? '#fff' : C.navy }}>
|
||
<div style={{ fontSize: '12px', fontWeight: 500, marginBottom: '4px' }}>{m.sender_role === 'admin' ? 'Staff' : 'Guest'}</div>
|
||
<div style={{ fontSize: '14px' }}>{m.message}</div>
|
||
<div style={{ fontSize: '11px', opacity: 0.7, marginTop: '4px' }}>{new Date(m.created_at).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
<div ref={messagesEndRef} />
|
||
</div>
|
||
<div style={{ padding: '12px', borderTop: `1px solid ${C.border}`, display: 'flex', gap: '8px' }}>
|
||
<input value={reply} onChange={e => setReply(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') sendReply(); }} placeholder="Type a reply..." style={{ flex: 1, padding: '10px 14px', borderRadius: 8, border: `1px solid ${C.border}`, fontSize: '14px', outline: 'none' }} />
|
||
<Btn onClick={sendReply} disabled={!reply.trim()}>Send</Btn>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: C.textLight }}>Select a conversation to view messages</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.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 [loading, setLoading] = useState(true);
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
useEffect(() => {
|
||
loadConfig();
|
||
}, []);
|
||
|
||
const loadConfig = async () => {
|
||
try {
|
||
const res = await fetch('/api/site-assets');
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
if (data.data) {
|
||
setConfig(prev => ({ ...prev, ...data.data }));
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load config:', err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
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.data }));
|
||
onToast('Image uploaded!', 'success');
|
||
}
|
||
} catch { onToast('Upload failed', 'error'); }
|
||
};
|
||
input.click();
|
||
};
|
||
|
||
const saveConfig = async () => {
|
||
setSaving(true);
|
||
try {
|
||
const res = await fetch('/api/site-assets', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ values: config }),
|
||
});
|
||
if (res.ok) {
|
||
onToast('Configuration saved!', 'success');
|
||
} else {
|
||
onToast('Failed to save', 'error');
|
||
}
|
||
} catch {
|
||
onToast('Failed to save', 'error');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
if (loading) return <div style={{ padding: 20, color: C.textLight }}>Loading...</div>;
|
||
|
||
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>
|
||
|
||
<SecHead title="📞 Contact & Social" />
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '16px', marginBottom: 20 }}>
|
||
<div style={{ background: C.card, borderRadius: 16, padding: '16px', boxShadow: C.shadow, border: `1px solid ${C.border}` }}>
|
||
<div style={{ fontSize: '13px', fontWeight: 600, color: C.text, marginBottom: '8px' }}>📍 Address</div>
|
||
<I value={config.address} onChange={(v: string) => setConfig(prev => ({ ...prev, address: v }))} placeholder="Full address" />
|
||
</div>
|
||
<div style={{ background: C.card, borderRadius: 16, padding: '16px', boxShadow: C.shadow, border: `1px solid ${C.border}` }}>
|
||
<div style={{ fontSize: '13px', fontWeight: 600, color: C.text, marginBottom: '8px' }}>📞 Phone</div>
|
||
<I value={config.phone} onChange={(v: string) => setConfig(prev => ({ ...prev, phone: v }))} placeholder="+593 99 999 9999" />
|
||
</div>
|
||
<div style={{ background: C.card, borderRadius: 16, padding: '16px', boxShadow: C.shadow, border: `1px solid ${C.border}` }}>
|
||
<div style={{ fontSize: '13px', fontWeight: 600, color: C.text, marginBottom: '8px' }}>✉️ Email</div>
|
||
<I value={config.email} onChange={(v: string) => setConfig(prev => ({ ...prev, email: v }))} placeholder="contact@lahuasca.com" />
|
||
</div>
|
||
<div style={{ background: C.card, borderRadius: 16, padding: '16px', boxShadow: C.shadow, border: `1px solid ${C.border}` }}>
|
||
<div style={{ fontSize: '13px', fontWeight: 600, color: C.text, marginBottom: '8px' }}>📘 Facebook URL</div>
|
||
<I value={config.facebook_url} onChange={(v: string) => setConfig(prev => ({ ...prev, facebook_url: v }))} placeholder="https://facebook.com/..." />
|
||
</div>
|
||
<div style={{ background: C.card, borderRadius: 16, padding: '16px', boxShadow: C.shadow, border: `1px solid ${C.border}` }}>
|
||
<div style={{ fontSize: '13px', fontWeight: 600, color: C.text, marginBottom: '8px' }}>📷 Instagram URL</div>
|
||
<I value={config.instagram_url} onChange={(v: string) => setConfig(prev => ({ ...prev, instagram_url: v }))} placeholder="https://instagram.com/..." />
|
||
</div>
|
||
<div style={{ background: C.card, borderRadius: 16, padding: '16px', boxShadow: C.shadow, border: `1px solid ${C.border}` }}>
|
||
<div style={{ fontSize: '13px', fontWeight: 600, color: C.text, marginBottom: '8px' }}>💬 WhatsApp URL</div>
|
||
<I value={config.whatsapp_url} onChange={(v: string) => setConfig(prev => ({ ...prev, whatsapp_url: v }))} placeholder="https://wa.me/..." />
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '16px' }}>
|
||
<Btn onClick={saveConfig} disabled={saving}>{saving ? 'Saving...' : '💾 Save Configuration'}</Btn>
|
||
</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 })} />
|
||
<I label="New Password (leave blank to keep current)" type="password" value={editing.password || ''} onChange={(v: string) => setEditing({ ...editing, password: v })} placeholder="Enter new password to change" />
|
||
<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} />;
|
||
case 'trips': return <TripsSection onToast={showToast} />;
|
||
case 'messages': return <MessagesSection 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 }}>
|
||
🏨 Hosteria 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>
|
||
);
|
||
}
|