From 4b6e7f43624836e436a11a7c4fc9f07e1b0d761c Mon Sep 17 00:00:00 2001 From: muken Date: Sun, 28 Jun 2026 13:43:02 -0500 Subject: [PATCH] feat: menu visibility toggle + drag-and-drop reorder in admin --- src/app/admin/page.tsx | 167 +++++++++++++++++++++++++++--- src/app/api/menu/reorder/route.ts | 30 ++++++ 2 files changed, 184 insertions(+), 13 deletions(-) create mode 100644 src/app/api/menu/reorder/route.ts diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index e7a76a8..dc05ef4 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -642,6 +642,8 @@ function RoomEditor({ room, setRoom, images, onSave, onCancel }: any) { function MenuSection({ items, setItems, images, onToast }: any) { const [editing, setEditing] = useState(null); const [confirmDel, setConfirmDel] = useState(null); + const [dragIdx, setDragIdx] = useState(null); + const [overIdx, setOverIdx] = useState(null); const save = async (item: MenuItem) => { try { @@ -650,32 +652,171 @@ function MenuSection({ items, setItems, images, onToast }: any) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(item), }); - if (res.ok) { setEditing(null); setItems((i: any) => i.map((x: MenuItem) => x.id === item.id ? item : x)); onToast('Menu item saved!', 'success'); } + if (res.ok) { setEditing(null); setItems((i: any) => i.map((x: any) => x.id === item.id ? item : x)); onToast('Menu item saved!', 'success'); } } catch { onToast('Error', 'error'); } }; const del = async (id: number) => { await fetch(`/api/menu/${id}`, { method: 'DELETE' }); - setItems((i: any) => i.filter((x: MenuItem) => x.id !== id)); + 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 (
setEditing({ id: 0, category: '', name: '', description: '', price: '', is_daily_special: false, active: true, sort_order: items.length })} size="sm">+ New Item} /> - {items.length === 0 ? : ( -
- {items.map((item: MenuItem) => ( - - setEditing(item)}>Edit - setConfirmDel(item.id)}>Del - - } /> - ))} + {sorted.length === 0 ? : ( +
+ {/* Drag handle instruction */} +
+ Drag items to reorder · Toggle visibility to hide from the public site +
+ + {sorted.map((item: MenuItem, idx: number) => { + const isDragging = dragIdx === idx; + const isOver = overIdx === idx && dragIdx !== idx; + return ( +
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 */} + { e.dataTransfer!.effectAllowed = 'move'; }}>⠿ + + {/* Position number */} + + {idx + 1} + + + {/* Visible/invisible toggle */} +
)} {editing && setEditing(null)} />} diff --git a/src/app/api/menu/reorder/route.ts b/src/app/api/menu/reorder/route.ts new file mode 100644 index 0000000..1aab782 --- /dev/null +++ b/src/app/api/menu/reorder/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireRole } from '@/lib/auth'; +import { db } from '@/lib/db'; + +export async function POST(request: NextRequest) { + try { + await requireRole('admin'); + const body = await request.json(); + const { order } = body; + + if (!Array.isArray(order) || order.length === 0) { + return NextResponse.json({ error: 'Missing or invalid order array' }, { status: 400 }); + } + + // Each entry: { id: number, sort_order: number } + const updates = order.map((item: { id: number; sort_order: number }, index: number) => + db.query( + 'UPDATE menu_items SET sort_order = $1 WHERE id = $2', + [index, item.id] + ) + ); + + await Promise.all(updates); + + return NextResponse.json({ ok: true }); + } catch (error: any) { + console.error('POST /api/menu/reorder error:', error); + return NextResponse.json({ error: error.message || 'Failed to reorder menu items' }, { status: 500 }); + } +}