feat: menu visibility toggle + drag-and-drop reorder in admin
This commit is contained in:
+154
-13
@@ -642,6 +642,8 @@ function RoomEditor({ room, setRoom, images, onSave, onCancel }: any) {
|
||||
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) => {
|
||||
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 (
|
||||
<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>} />
|
||||
{items.length === 0 ? <Empty icon="🍽️" title="No menu items" /> : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '16px' }}>
|
||||
{items.map((item: MenuItem) => (
|
||||
<EntityCard key={item.id} title={item.name} desc={item.description || ''} meta={`${item.category}${item.price ? ' • ' + item.price : ''}`}
|
||||
actions={
|
||||
<>
|
||||
<Btn size="sm" variant="secondary" onClick={() => setEditing(item)}>Edit</Btn>
|
||||
<Btn size="sm" variant="danger" onClick={() => setConfirmDel(item.id)}>Del</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={() => 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={(e) => { e.dataTransfer!.effectAllowed = 'move'; }}>⠿</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)} />}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user