From 40f4c102b4523f1b09c04d7c9c8b261ddff90444 Mon Sep 17 00:00:00 2001 From: David Dyer Date: Mon, 31 Aug 2026 22:12:24 -0400 Subject: [PATCH] Add mobile nav toggle, responsive admin tables, and menu reordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Public site: replace the always-hidden mobile nav with a hamburger toggle (index.php, menu.php, main.js, styles.css) - Admin: render the Menu/Announcements/Users tables as labeled cards below ~760px instead of squeezing descriptions into unreadable single-word-per-line columns - Admin menu: replace the manual "Order" number (which allowed duplicates and never reflected in real time) with ↑/↓ buttons that reorder items and categories immediately and renumber sort_order sequentially on every move, self-healing any stale duplicates Co-Authored-By: Claude Sonnet 5 --- admin/index.php | 12 ++-- admin/menu.js | 176 ++++++++++++++++++++++++++++++++++++++++-------- admin/menu.php | 71 ++++++++++++------- admin/users.js | 8 +-- admin/users.php | 8 +-- inc/menu.php | 110 ++++++++++++++++++++++++++++-- index.php | 30 ++++----- main.js | 66 ++++++++++++++++++ menu.php | 17 ++++- styles.css | 149 ++++++++++++++++++++++++++++++++++++++++ 10 files changed, 553 insertions(+), 94 deletions(-) create mode 100644 main.js diff --git a/admin/index.php b/admin/index.php index 9c81d18..c884cf2 100644 --- a/admin/index.php +++ b/admin/index.php @@ -219,7 +219,7 @@ $today = date('Y-m-d'); (empty($ann['end_date']) || $ann['end_date'] >= $today); ?> - + ● Live @@ -228,23 +228,23 @@ $today = date('Y-m-d'); ○ Off - +
- + - + → Always (while on) - + - +
Edit diff --git a/admin/menu.js b/admin/menu.js index 2709fd4..e04220b 100644 --- a/admin/menu.js +++ b/admin/menu.js @@ -16,7 +16,6 @@ function openItemModal(itemData = null) { const priceField = document.getElementById('item-field-price'); const badgeField = document.getElementById('item-field-badge'); const descField = document.getElementById('item-field-desc'); - const sortField = document.getElementById('item-field-sort'); const availField = document.getElementById('item-field-available'); if (itemData) { @@ -27,7 +26,6 @@ function openItemModal(itemData = null) { priceField.value = itemData.price || ''; badgeField.value = itemData.badge || ''; descField.value = itemData.description || ''; - sortField.value = itemData.sort_order || 0; availField.checked = parseInt(itemData.is_available, 10) === 1; } else { title.textContent = '✨ Add Menu Item'; @@ -36,7 +34,6 @@ function openItemModal(itemData = null) { priceField.value = ''; badgeField.value = ''; descField.value = ''; - sortField.value = 0; availField.checked = true; } @@ -53,20 +50,17 @@ function openCategoryModal(catData = null) { const idField = document.getElementById('cat-field-id'); const nameField = document.getElementById('cat-field-name'); const descField = document.getElementById('cat-field-desc'); - const sortField = document.getElementById('cat-field-sort'); if (catData) { title.textContent = '✏️ Edit Category'; idField.value = catData.id || 0; nameField.value = catData.name || ''; descField.value = catData.description || ''; - sortField.value = catData.sort_order || 0; } else { title.textContent = '✨ Add Category'; idField.value = 0; nameField.value = ''; descField.value = ''; - sortField.value = 0; } catModal.classList.add('is-open'); @@ -163,12 +157,84 @@ document.getElementById('categories-container').addEventListener('click', async openCategoryModal({ id: target.dataset.id, name: target.dataset.name, - description: target.dataset.desc, - sort_order: target.dataset.sort + description: target.dataset.desc }); return; } + // Move Item Up/Down + if (target.classList.contains('btn-move-item')) { + const direction = target.dataset.direction; + const row = target.closest('tr'); + const tbody = row.parentElement; + const sibling = direction === 'up' ? row.previousElementSibling : row.nextElementSibling; + if (!sibling) return; // already at this edge; button should be disabled anyway + + target.disabled = true; + const fd = new FormData(); + fd.append('action', 'move_item'); + fd.append('item_id', row.dataset.itemId); + fd.append('direction', direction); + fd.append('csrf_token', csrfToken); + + try { + const data = await postForm(fd); + if (!data.success) { + alert(data.error || 'Failed to reorder item.'); + target.disabled = false; + return; + } + if (direction === 'up') { + tbody.insertBefore(row, sibling); + } else { + tbody.insertBefore(sibling, row); + } + // Re-derives disabled state from DOM position rather than re-enabling + // `target` unconditionally, since the swap may have just moved it + // onto an edge (e.g. into the first/last slot) where it should stay disabled. + refreshMoveButtonStates(tbody); + } catch { + alert('Network error. Please try again.'); + target.disabled = false; + } + return; + } + + // Move Category Up/Down + if (target.classList.contains('btn-move-cat')) { + const direction = target.dataset.direction; + const panel = target.closest('.category-panel'); + const container = panel.parentElement; + const sibling = direction === 'up' ? panel.previousElementSibling : panel.nextElementSibling; + if (!sibling) return; + + target.disabled = true; + const fd = new FormData(); + fd.append('action', 'move_category'); + fd.append('category_id', target.dataset.id); + fd.append('direction', direction); + fd.append('csrf_token', csrfToken); + + try { + const data = await postForm(fd); + if (!data.success) { + alert(data.error || 'Failed to reorder category.'); + target.disabled = false; + return; + } + if (direction === 'up') { + container.insertBefore(panel, sibling); + } else { + container.insertBefore(sibling, panel); + } + refreshCategoryMoveButtonStates(); + } catch { + alert('Network error. Please try again.'); + target.disabled = false; + } + return; + } + // Toggle Availability (Mark Sold Out / Mark Available) if (target.classList.contains('btn-toggle-item')) { const row = target.closest('tr'); @@ -203,6 +269,7 @@ document.getElementById('categories-container').addEventListener('click', async if (target.classList.contains('btn-delete-item')) { if (!confirm('Are you sure you want to delete this menu item?')) return; const row = target.closest('tr'); + const tbody = row?.parentElement; const fd = new FormData(); fd.append('action', 'delete_item'); @@ -215,7 +282,10 @@ document.getElementById('categories-container').addEventListener('click', async row.style.transition = 'opacity 0.2s ease, transform 0.2s ease'; row.style.opacity = '0'; row.style.transform = 'translateX(-10px)'; - setTimeout(() => row.remove(), 200); + setTimeout(() => { + row.remove(); + refreshMoveButtonStates(tbody); + }, 200); } } catch { alert('Failed to delete item.'); @@ -239,7 +309,10 @@ document.getElementById('categories-container').addEventListener('click', async panel.style.transition = 'opacity 0.2s ease, transform 0.2s ease'; panel.style.opacity = '0'; panel.style.transform = 'translateY(-10px)'; - setTimeout(() => panel.remove(), 200); + setTimeout(() => { + panel.remove(); + refreshCategoryMoveButtonStates(); + }, 200); } } catch { alert('Failed to delete category.'); @@ -247,9 +320,64 @@ document.getElementById('categories-container').addEventListener('click', async } }); +// Sets the up/down move buttons' disabled state from each row's actual DOM +// position, rather than tracking indices by hand — first row can't move up, +// last can't move down, everything in between can do either. Call this +// after any insert/remove/reorder in a category's tbody. +function refreshMoveButtonStates(tbody) { + if (!tbody) return; + const rows = [...tbody.querySelectorAll('tr[data-item-id]')]; + rows.forEach((row, index) => { + const upBtn = row.querySelector('.btn-move-item[data-direction="up"]'); + const downBtn = row.querySelector('.btn-move-item[data-direction="down"]'); + if (upBtn) upBtn.disabled = index === 0; + if (downBtn) downBtn.disabled = index === rows.length - 1; + }); +} + +// Same idea as refreshMoveButtonStates(), for category panels. +function refreshCategoryMoveButtonStates() { + const panels = [...document.querySelectorAll('#categories-container .category-panel')]; + panels.forEach((panel, index) => { + const upBtn = panel.querySelector('.btn-move-cat[data-direction="up"]'); + const downBtn = panel.querySelector('.btn-move-cat[data-direction="down"]'); + if (upBtn) upBtn.disabled = index === 0; + if (downBtn) downBtn.disabled = index === panels.length - 1; + }); +} + +function itemRowHtml(item, isAvail) { + return ` + ${isAvail ? '● Available' : '○ Sold Out / Hidden'} + ${escapeHtml(item.name)}
${escapeHtml(item.description || '')}
+ ${escapeHtml(item.price)} + ${item.badge ? `${escapeHtml(item.badge)}` : '—'} +
+ + + + + +
+ `; +} + function updateOrInsertItemRow(item, isNew) { const isAvail = parseInt(item.is_available, 10) === 1; - const existingRow = document.getElementById(`item-row-${item.id}`); + let existingRow = document.getElementById(`item-row-${item.id}`); + + // An edit can reassign an item to a different category — when that + // happens the row needs to move tables, not just update in place, so + // detach it here and let it fall through to the insert path below. + if (existingRow && existingRow.closest('table')?.id !== `cat-table-${item.category_id}`) { + const oldTbody = existingRow.parentElement; + existingRow.remove(); + existingRow = null; + refreshMoveButtonStates(oldTbody); + if (oldTbody && !oldTbody.querySelector('tr[data-item-id]')) { + oldTbody.innerHTML = 'No items in this category yet.'; + } + } if (existingRow) { // Update existing row @@ -261,7 +389,6 @@ function updateOrInsertItemRow(item, isNew) { existingRow.querySelector('.item-name-cell').textContent = item.name; existingRow.querySelector('.item-desc-cell').textContent = item.description || ''; existingRow.querySelector('.item-price-cell').textContent = item.price; - existingRow.querySelector('.item-sort-cell').textContent = item.sort_order || 0; const badgeCell = existingRow.querySelector('.item-badge-cell'); badgeCell.innerHTML = item.badge @@ -272,7 +399,8 @@ function updateOrInsertItemRow(item, isNew) { return; } - // Item was created or moved — insert into matching category table + // Item was created or moved to this category — it always joins at the + // end (see next_item_sort_order() server-side), so just append it. const catTable = document.getElementById(`cat-table-${item.category_id}`); if (!catTable) return; @@ -283,21 +411,10 @@ function updateOrInsertItemRow(item, isNew) { tr.id = `item-row-${item.id}`; tr.dataset.itemId = item.id; tr.dataset.item = JSON.stringify(item); - - tr.innerHTML = ` - ${isAvail ? '● Available' : '○ Sold Out / Hidden'} - ${escapeHtml(item.name)}
${escapeHtml(item.description || '')}
- ${escapeHtml(item.price)} - ${item.badge ? `${escapeHtml(item.badge)}` : '—'} - ${item.sort_order || 0} -
- - - -
- `; + tr.innerHTML = itemRowHtml(item, isAvail); tbody.appendChild(tr); + refreshMoveButtonStates(tbody); } // Static shell for a category panel. Only text-content positions use @@ -314,6 +431,8 @@ function categoryPanelHtml(category) {

${escapeHtml(category.description || '')}

+ +
@@ -327,13 +446,12 @@ function categoryPanelHtml(category) { Item Price Tag - Order Actions - No items in this category yet. + No items in this category yet. @@ -353,7 +471,6 @@ function applyCategoryData(panel, category) { editBtn.dataset.id = category.id; editBtn.dataset.name = category.name; editBtn.dataset.desc = category.description || ''; - editBtn.dataset.sort = category.sort_order || 0; panel.querySelector('.btn-delete-cat').dataset.id = category.id; } @@ -401,4 +518,5 @@ function updateOrInsertCategoryPanel(category) { applyCategoryData(panel, category); syncCategoryOption(category); resortCategoryPanels(); + refreshCategoryMoveButtonStates(); } diff --git a/admin/menu.php b/admin/menu.php index ec9ddbc..fa35d47 100644 --- a/admin/menu.php +++ b/admin/menu.php @@ -42,7 +42,6 @@ function handle_save_item(array $post): array 'price' => $price, 'badge' => post_str($post, 'badge'), 'is_available' => isset($post['is_available']) ? 1 : 0, - 'sort_order' => (int) ($post['sort_order'] ?? 0), ], $itemId); return ['success' => true, 'item' => get_menu_item_by_id($savedId), 'is_new' => $itemId === null]; @@ -67,6 +66,20 @@ function handle_toggle_item(array $post): array ]; } +function handle_move_item(array $post): array +{ + $itemId = (int) ($post['item_id'] ?? 0); + $direction = post_str($post, 'direction'); + if ($itemId <= 0 || !get_menu_item_by_id($itemId)) { + return ['success' => false, 'error' => 'Menu item not found.']; + } + if (!in_array($direction, ['up', 'down'], true)) { + return ['success' => false, 'error' => 'Invalid direction.']; + } + + return move_menu_item($itemId, $direction); +} + function handle_delete_item(array $post): array { $itemId = (int) ($post['item_id'] ?? 0); @@ -93,12 +106,25 @@ function handle_save_category(array $post): array $savedId = save_category([ 'name' => $name, 'description' => post_str($post, 'description'), - 'sort_order' => (int) ($post['sort_order'] ?? 0), ], $catId); return ['success' => true, 'category' => get_category_by_id($savedId), 'is_new' => $catId === null]; } +function handle_move_category(array $post): array +{ + $catId = (int) ($post['category_id'] ?? 0); + $direction = post_str($post, 'direction'); + if ($catId <= 0 || !get_category_by_id($catId)) { + return ['success' => false, 'error' => 'Category not found.']; + } + if (!in_array($direction, ['up', 'down'], true)) { + return ['success' => false, 'error' => 'Invalid direction.']; + } + + return move_category($catId, $direction); +} + function handle_delete_category(array $post): array { $catId = (int) ($post['category_id'] ?? 0); @@ -115,8 +141,10 @@ function handle_menu_action(string $action, array $post): array return match ($action) { 'save_item' => handle_save_item($post), 'toggle_item' => handle_toggle_item($post), + 'move_item' => handle_move_item($post), 'delete_item' => handle_delete_item($post), 'save_category' => handle_save_category($post), + 'move_category' => handle_move_category($post), 'delete_category' => handle_delete_category($post), default => ['success' => false, 'error' => 'Unknown action'], }; @@ -196,7 +224,8 @@ $menuGrouped = get_full_menu(false); - + + $category): ?>
@@ -206,11 +235,12 @@ $menuGrouped = get_full_menu(false);

+ + +
- -
- - -
@@ -374,11 +398,6 @@ $menuGrouped = get_full_menu(false);
- -
- - -
diff --git a/admin/users.js b/admin/users.js index 547a64e..7600de4 100644 --- a/admin/users.js +++ b/admin/users.js @@ -197,10 +197,10 @@ function insertUserRow(user) { // HTML string — escapeHtml() only escapes for text-content positions, // not attribute values, so it can't safely build an attribute string. tr.innerHTML = ` - ${escapeHtml(user.username)} - ${escapeHtml(user.role.charAt(0).toUpperCase() + user.role.slice(1))} - ${escapeHtml(user.created_at)} -
+ ${escapeHtml(user.username)} + ${escapeHtml(user.role.charAt(0).toUpperCase() + user.role.slice(1))} + ${escapeHtml(user.created_at)} +
diff --git a/admin/users.php b/admin/users.php index d56561d..af848c6 100644 --- a/admin/users.php +++ b/admin/users.php @@ -195,15 +195,15 @@ $users = get_all_users(); - + (you) - - - + + +
+
- + \ No newline at end of file diff --git a/main.js b/main.js new file mode 100644 index 0000000..8614554 --- /dev/null +++ b/main.js @@ -0,0 +1,66 @@ +// Shared public-site behavior: scroll-reveal animations and the mobile +// nav toggle. Loaded by both index.php and menu.php. + +// Reveal-on-scroll +(() => { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + + const els = document.querySelectorAll('.reveal'); + if (!('IntersectionObserver' in window)) { + els.forEach((el) => el.classList.add('is-visible')); + return; + } + + const io = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + entry.target.classList.add('is-visible'); + io.unobserve(entry.target); + } + }); + }, { threshold: 0.15 }); + + els.forEach((el) => io.observe(el)); +})(); + +// Mobile nav toggle +(() => { + const toggle = document.getElementById('nav-toggle'); + const nav = document.getElementById('primary-nav'); + if (!toggle || !nav) return; + + function closeNav() { + nav.classList.remove('is-open'); + toggle.setAttribute('aria-expanded', 'false'); + } + + function openNav() { + nav.classList.add('is-open'); + toggle.setAttribute('aria-expanded', 'true'); + } + + toggle.addEventListener('click', () => { + if (nav.classList.contains('is-open')) { + closeNav(); + } else { + openNav(); + } + }); + + // Close after following a link + nav.querySelectorAll('a').forEach((link) => { + link.addEventListener('click', closeNav); + }); + + // Close on Escape + window.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeNav(); + }); + + // Close on outside click + document.addEventListener('click', (e) => { + if (!nav.classList.contains('is-open')) return; + if (nav.contains(e.target) || toggle.contains(e.target)) return; + closeNav(); + }); +})(); diff --git a/menu.php b/menu.php index 2f26373..b133228 100644 --- a/menu.php +++ b/menu.php @@ -49,11 +49,22 @@ $siteUrl = rtrim(SITE_URL, '/'); THE OLD FITZ -
+ + diff --git a/styles.css b/styles.css index 449c70f..b76a8a5 100644 --- a/styles.css +++ b/styles.css @@ -343,6 +343,10 @@ color: var(--gold-bright); } + nav.links a.active { + color: var(--gold-bright); + } + .nav-cta { display: inline-flex; align-items: center; @@ -355,9 +359,67 @@ letter-spacing: 0.06em; } + /* mobile nav toggle: hidden on desktop, shown only under the nav.links + breakpoint below. Icon swap is driven by the button's own + aria-expanded state rather than a second JS-toggled class. */ + .nav-toggle { + display: none; + background: transparent; + border: none; + padding: 0.5rem; + margin: -0.5rem; + color: var(--ink); + cursor: pointer; + align-items: center; + justify-content: center; + } + + .nav-toggle svg { + width: 1.5rem; + height: 1.5rem; + } + + .nav-toggle .icon-close { + display: none; + } + + .nav-toggle[aria-expanded="true"] .icon-menu { + display: none; + } + + .nav-toggle[aria-expanded="true"] .icon-close { + display: block; + } + @media (max-width: 47.5em) { + .nav-toggle { + display: inline-flex; + } + + .nav-cta { + display: none; + } + nav.links { display: none; + position: absolute; + top: 100%; + left: 0; + right: 0; + flex-direction: column; + align-items: stretch; + gap: 0; + background: var(--ground-2); + border-bottom: 1px solid var(--rule); + padding: 0.5rem 0; + } + + nav.links.is-open { + display: flex; + } + + nav.links a { + padding: 0.9rem 1.5rem; } } @@ -470,6 +532,12 @@ transform: translateY(-1px); } + .btn:disabled { + opacity: 0.35; + cursor: not-allowed; + transform: none; + } + /* :where() keeps this native-button reset at zero specificity, so modifier classes like .btn-primary's background can actually win instead of losing to button.btn's higher (0,1,1) specificity */ @@ -1124,6 +1192,22 @@ text-decoration: underline; } + @media (max-width: 47.5em) { + .admin-nav-inner { + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } + + /* two classes each, so these reliably outrank .admin-nav-links's and + .admin-user's own base-rule gap regardless of source order */ + .admin-nav-inner .admin-nav-links, + .admin-nav-inner .admin-user { + flex-wrap: wrap; + gap: 0.5rem 1rem; + } + } + .dashboard-layout { padding-block: 2.5rem 4rem; } @@ -1155,8 +1239,10 @@ .panel-head { display: flex; + flex-wrap: wrap; justify-content: space-between; align-items: center; + gap: 0.75rem; margin-bottom: 1.5rem; } @@ -1279,12 +1365,74 @@ white-space: nowrap; } + /* Below ~760px a plain forces long text (item descriptions, + headlines) into a column so narrow it wraps one word per line. Recast + each row as a labeled card instead — the label comes from the td's + data-label attribute, kept in sync with the
text in each admin + page (and its matching *.js row template, where rows are rebuilt + client-side). */ + @media (max-width: 47.5em) { + .admin-table-wrap { + overflow-x: visible; + } + + .admin-table thead { + display: none; + } + + .admin-table, + .admin-table tbody, + .admin-table tr, + .admin-table td { + display: block; + width: 100%; + } + + .admin-table tr { + border: 1px solid var(--rule); + border-radius: 0.25rem; + padding: 0.9rem 1rem; + margin-bottom: 0.85rem; + } + + .admin-table tr:last-child { + margin-bottom: 0; + } + + .admin-table td { + padding: 0.4rem 0; + border-bottom: none; + } + + .admin-table td[data-label]::before { + content: attr(data-label); + display: block; + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--gold); + margin-bottom: 0.3rem; + } + + .admin-table-actions { + flex-wrap: wrap; + white-space: normal; + } + } + .btn-sm { padding: 0.4rem 0.8rem; font-size: 0.75rem; line-height: 1.2; } + /* Single-glyph reorder buttons (↑ / ↓) — narrower than .btn-sm's text + padding since there's no label to give it room to breathe. */ + .btn-icon { + padding-inline: 0.55rem; + } + .btn-danger { background: var(--danger-bg); border: 1px solid rgba(220, 80, 60, 0.5); @@ -1539,6 +1687,7 @@ .admin-toolbar { display: flex; + flex-wrap: wrap; gap: 0.75rem; align-items: center; margin-bottom: 2rem;