feat: add photo support to food menu items

- Add photos and featured_photo columns to menu_items table
- Update MenuEditor with photo upload functionality
- Add click-to-set-featured-photo interaction
- Update menu API endpoints to handle photos
This commit is contained in:
2026-06-30 00:04:04 -05:00
parent f9c8d48631
commit b46d490be2
4 changed files with 60 additions and 12 deletions
+50 -4
View File
@@ -8,7 +8,7 @@ type Slide = { id: number; title: string; subtitle: string; image_url: string |
type Room = { id: number; name: string; description: string; price: string; photos: string[]; featured_photo: string | null; active: boolean; sort_order: number; amenity_ids?: 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 MenuItem = { id: number; category: string; name: string; description: string; price: string; is_daily_special: boolean; active: boolean; sort_order: number; photos: string[]; featured_photo: string | null };
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 };
@@ -1408,7 +1408,7 @@ function MenuSection({ items, setItems, images, onToast }: any) {
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>} />
action={<Btn onClick={() => setEditing({ id: 0, category: '', name: '', description: '', price: '', is_daily_special: false, active: true, sort_order: items.length, photos: [], featured_photo: null })} 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 */}
@@ -1523,16 +1523,62 @@ function MenuSection({ items, setItems, images, onToast }: any) {
}
function MenuEditor({ item, setItem, onSave, onCancel }: any) {
const i = { ...item };
const [localPhotos, setLocalPhotos] = useState<string[]>(item.photos || []);
const i = { ...item, 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: 'menu', room_id: null, location_target: `menu-${i.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: 460, width: '90%' }}>
<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 }}>{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 })} />
{/* Photo upload */}
<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(`menu-photos-${i.id}`)?.click()} size="sm" variant="secondary">+ Add Photos</Btn>
<input id={`menu-photos-${i.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, idx) => (
<div key={idx} style={{ position: 'relative' }}>
<img
src={ph}
alt=""
onClick={() => setItem({ ...i, featured_photo: ph })}
style={{
width: 64, height: 48, objectFit: 'cover', borderRadius: 6, border: i.featured_photo === ph ? `2px solid ${C.gold}` : '1px solid #eee',
cursor: 'pointer',
}}
/>
<button onClick={() => setLocalPhotos(prev => prev.filter((_, j) => j !== idx))}
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>
{localPhotos.length > 0 && <div style={{ fontSize: '11px', color: C.textLight }}>Click a photo to set as featured</div>}
</div>
<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>
+4 -4
View File
@@ -8,12 +8,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const { id: idStr } = await params;
const id = parseInt(idStr, 10);
const body = await request.json();
const { category, name, description, price, is_daily_special, active, sort_order } = body;
const { category, name, description, price, is_daily_special, active, sort_order, photos, featured_photo } = body;
const { rows } = await db.query(
`UPDATE menu_items SET category = $1, name = $2, description = $3, price = $4, is_daily_special = $5, active = $6, sort_order = $7
WHERE id = $8 RETURNING *`,
[category, name, description || '', price, is_daily_special, active, sort_order || 0, id]
`UPDATE menu_items SET category = $1, name = $2, description = $3, price = $4, is_daily_special = $5, active = $6, sort_order = $7, photos = $8, featured_photo = $9
WHERE id = $10 RETURNING *`,
[category, name, description || '', price, is_daily_special, active, sort_order || 0, photos || [], featured_photo || null, id]
);
if (rows.length === 0) {
+4 -4
View File
@@ -18,17 +18,17 @@ export async function POST(request: NextRequest) {
try {
await requireRole('admin');
const body = await request.json();
const { category, name, description, price, is_daily_special, sort_order } = body;
const { category, name, description, price, is_daily_special, sort_order, photos, featured_photo } = body;
if (!category || !name || price === undefined || price === '') {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
const { rows } = await db.query(
`INSERT INTO menu_items (category, name, description, price, is_daily_special, sort_order)
VALUES ($1, $2, $3, $4, $5, $6)
`INSERT INTO menu_items (category, name, description, price, is_daily_special, sort_order, photos, featured_photo)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[category, name, description || '', price, is_daily_special || false, sort_order || 0]
[category, name, description || '', price, is_daily_special || false, sort_order || 0, photos || [], featured_photo || null]
);
return NextResponse.json({ data: rows[0] });
+2
View File
@@ -100,6 +100,8 @@ export async function initSchema() {
is_daily_special BOOLEAN DEFAULT FALSE,
active BOOLEAN DEFAULT TRUE,
sort_order INTEGER DEFAULT 0,
photos TEXT[] DEFAULT '{}',
featured_photo VARCHAR(500),
created_at TIMESTAMP DEFAULT NOW()
);
`);