From cd4c6c1969f42d889c8e890e885779d04f9ec4aa Mon Sep 17 00:00:00 2001 From: David Dyer Date: Tue, 1 Sep 2026 14:10:08 -0400 Subject: [PATCH] Add menu enable toggle, user status, and mobile/UI polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Admin menu page: mobile item cards now put Actions in the top-right corner next to Status, matching the desktop row layout instead of stacking at the bottom; same treatment applied to announcements and users for consistency across all three admin lists - Center button label text everywhere via .btn { justify-content: center } instead of one-off overrides - Add a Disable/Enable Menu toggle to the admin menu toolbar; the public menu page shows its existing "menu being updated" message while disabled - Add an Enabled/Disabled status to user accounts, with its own toggle badge, guardrails against disabling your own account or the last enabled account, and an immediate session kick for anyone disabled mid-session - Fix the announcement banner / hero tagline "·" separators to hide only when their neighboring text actually wraps onto a different line, instead of a fixed breakpoint or always-on dot Co-Authored-By: Claude Sonnet 5 --- admin/index.php | 16 +++--- admin/menu.js | 42 +++++++++++++++ admin/menu.php | 15 ++++++ admin/users.js | 40 ++++++++++++-- admin/users.php | 63 ++++++++++++++++++---- inc/auth.php | 15 +++++- inc/db.php | 31 +++++++++++ inc/menu.php | 16 ++++++ inc/users.php | 15 +++++- index.php | 4 +- main.js | 53 +++++++++++++++++++ menu.php | 7 +-- styles.css | 138 ++++++++++++++++++++++++++++++++++++++++-------- 13 files changed, 405 insertions(+), 50 deletions(-) diff --git a/admin/index.php b/admin/index.php index 016627a..600045b 100644 --- a/admin/index.php +++ b/admin/index.php @@ -215,14 +215,16 @@ $announcements = get_all_announcements();
- Edit +
+ Edit -
- - - - -
+
+ + + + +
+
diff --git a/admin/menu.js b/admin/menu.js index e3fdcd9..5b81142 100644 --- a/admin/menu.js +++ b/admin/menu.js @@ -86,9 +86,51 @@ async function postForm(formData) { return response.json(); } +// Applies a menu_enabled state to the toggle button and its alert banner — +// shared by the click handler below and, implicitly, the server-rendered +// initial state (see menu.php), so the two never drift apart. +function applyMenuEnabledState(enabled) { + const btn = document.getElementById('btn-toggle-menu'); + btn.textContent = enabled ? 'Disable Menu' : 'Enable Menu'; + btn.classList.toggle('btn-danger', enabled); + btn.classList.toggle('btn-outline', !enabled); + btn.dataset.enabled = enabled ? '1' : '0'; + + document.getElementById('menu-disabled-alert').hidden = enabled; +} + // Modal listeners document.getElementById('btn-new-item').addEventListener('click', () => openItemModal()); document.getElementById('btn-new-cat').addEventListener('click', () => openCategoryModal()); + +document.getElementById('btn-toggle-menu').addEventListener('click', async (e) => { + const btn = e.currentTarget; + const isCurrentlyEnabled = btn.dataset.enabled === '1'; + + // Only the disabling direction needs a confirm — it immediately hides + // the menu from every visitor, where re-enabling it is harmless. + if (isCurrentlyEnabled && !confirm('Hide the menu from the public site? Visitors will see a "menu being updated" message until you re-enable it.')) { + return; + } + + btn.disabled = true; + const fd = new FormData(); + fd.append('action', 'toggle_menu_enabled'); + fd.append('csrf_token', csrfToken); + + try { + const data = await postForm(fd); + if (data.success) { + applyMenuEnabledState(data.menu_enabled); + } else { + alert(data.error || 'Failed to update menu visibility.'); + } + } catch { + alert('Network error. Please try again.'); + } finally { + btn.disabled = false; + } +}); document.getElementById('btn-create-first-cat')?.addEventListener('click', () => openCategoryModal()); document.getElementById('btn-close-item-modal').addEventListener('click', closeItemModal); document.getElementById('btn-cancel-item').addEventListener('click', closeItemModal); diff --git a/admin/menu.php b/admin/menu.php index ee73eb7..4bbc324 100644 --- a/admin/menu.php +++ b/admin/menu.php @@ -135,6 +135,12 @@ function handle_delete_category(array $post): array return ['success' => true]; } +function handle_toggle_menu_enabled(): array +{ + set_menu_enabled(!is_menu_enabled()); + return ['success' => true, 'menu_enabled' => is_menu_enabled()]; +} + function handle_menu_action(string $action, array $post): array { return match ($action) { @@ -145,6 +151,7 @@ function handle_menu_action(string $action, array $post): array 'save_category' => handle_save_category($post), 'move_category' => handle_move_category($post), 'delete_category' => handle_delete_category($post), + 'toggle_menu_enabled' => handle_toggle_menu_enabled(), default => ['success' => false, 'error' => 'Unknown action'], }; } @@ -167,6 +174,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $categories = get_all_categories(); $menuGrouped = get_full_menu(false); +$menuEnabled = is_menu_enabled(); ?> @@ -213,6 +221,13 @@ $menuGrouped = get_full_menu(false);
+ +
+ +
diff --git a/admin/users.js b/admin/users.js index 40f3044..46f1969 100644 --- a/admin/users.js +++ b/admin/users.js @@ -152,10 +152,38 @@ document.getElementById('form-password').addEventListener('submit', async (e) => } }); -// Delegate row actions: Change Password, Delete +// Delegate row actions: toggle Status, Change Password, Delete document.getElementById('users-rows').addEventListener('click', async (e) => { const { target } = e; + // The status badge itself is the toggle button — same convention as the + // menu items and announcements lists. Self's badge renders as a plain + // (non-button) span, so this never matches your own row. + if (target.classList.contains('btn-toggle-user')) { + target.disabled = true; + + const fd = new FormData(); + fd.append('action', 'toggle_user_enabled'); + fd.append('user_id', target.dataset.id); + fd.append('csrf_token', csrfToken); + + try { + const data = await postForm(fd); + if (data.success) { + target.className = `status-badge btn-toggle-user ${data.status_class}`; + target.textContent = data.status_badge; + target.title = `Click to mark as ${data.is_enabled ? 'disabled' : 'enabled'}`; + } else { + alert(data.error || 'Failed to update user status.'); + } + } catch { + alert('Network error. Please try again.'); + } finally { + target.disabled = false; + } + return; + } + if (target.classList.contains('btn-change-password')) { openPasswordModal(target.dataset.id, target.dataset.username); return; @@ -197,14 +225,20 @@ function insertUserRow(user) { // data-username is set via dataset below, never interpolated into the // HTML string — escapeHtml() only escapes for text-content positions, // not attribute values, so it can't safely build an attribute string. + // A user you just created is never "you" — the current admin's own row + // already exists — so its Status badge always renders as the clickable + // toggle, never the plain self span (see users.php for that variant). row.innerHTML = ` +
+ +
${escapeHtml(user.username)}
${escapeHtml(user.role.charAt(0).toUpperCase() + user.role.slice(1))}
${escapeHtml(user.created_at)}
-
+
-
+
`; row.querySelector('.btn-change-password').dataset.username = user.username; diff --git a/admin/users.php b/admin/users.php index 3ca0452..bf666b9 100644 --- a/admin/users.php +++ b/admin/users.php @@ -93,6 +93,32 @@ function handle_change_password(array $post, array $currentUser): array { return ['success' => true]; } +function handle_toggle_user_enabled(array $post, array $currentUser): array { + $targetId = (int) ($post['user_id'] ?? 0); + $target = $targetId > 0 ? get_user_by_id($targetId) : null; + if (!$target) { + return ['success' => false, 'error' => 'User not found.']; + } + if ($targetId === (int) $currentUser['id']) { + return ['success' => false, 'error' => "You can't disable your own account while logged in as it."]; + } + + $currentlyEnabled = (int) $target['is_enabled'] === 1; + if ($currentlyEnabled && count_enabled_users() <= 1) { + return ['success' => false, 'error' => 'At least one user account must remain enabled.']; + } + + toggle_user_enabled($targetId); + $isEnabled = (int) get_user_by_id($targetId)['is_enabled'] === 1; + + return [ + 'success' => true, + 'is_enabled' => $isEnabled, + 'status_badge' => $isEnabled ? '● Enabled' : '○ Disabled', + 'status_class' => $isEnabled ? 'active' : 'inactive', + ]; +} + function handle_delete_user(array $post, array $currentUser): array { $targetId = (int) ($post['user_id'] ?? 0); if ($targetId <= 0 || !get_user_by_id($targetId)) { @@ -113,6 +139,7 @@ function handle_users_action(string $action, array $post, array $currentUser): a return match ($action) { 'create_user' => handle_create_user($post, $currentUser), 'change_password' => handle_change_password($post, $currentUser), + 'toggle_user_enabled' => handle_toggle_user_enabled($post, $currentUser), 'delete_user' => handle_delete_user($post, $currentUser), default => ['success' => false, 'error' => 'Unknown action'], }; @@ -191,6 +218,7 @@ $users = get_all_users();
+ +
+
+ + + + + + + +
- + (you)
@@ -209,16 +250,18 @@ $users = get_all_users();
- - - - + + + +
diff --git a/inc/auth.php b/inc/auth.php index 9e06ec9..6a933f0 100644 --- a/inc/auth.php +++ b/inc/auth.php @@ -25,6 +25,19 @@ function require_auth(): void { header('Location: login.php'); exit; } + + // A user disabled mid-session should lose access immediately rather + // than keep it until their session expires — re-check against the DB + // instead of trusting the session's now-possibly-stale copy. + $pdo = get_db(); + $stmt = $pdo->prepare("SELECT is_enabled FROM users WHERE id = ?"); + $stmt->execute([$_SESSION['admin_user_id']]); + $stillEnabled = $stmt->fetchColumn(); + if ($stillEnabled === false || (int) $stillEnabled !== 1) { + logout_user(); + header('Location: login.php'); + exit; + } } function get_logged_in_user(): ?array { @@ -44,7 +57,7 @@ function authenticate_user(string $username, string $password): bool { $stmt->execute([trim($username)]); $user = $stmt->fetch(); - if ($user && password_verify($password, $user['password_hash'])) { + if ($user && (int) $user['is_enabled'] === 1 && password_verify($password, $user['password_hash'])) { session_regenerate_id(true); $_SESSION['admin_user_id'] = $user['id']; $_SESSION['admin_username'] = $user['username']; diff --git a/inc/db.php b/inc/db.php index 2b327a4..dd59e20 100644 --- a/inc/db.php +++ b/inc/db.php @@ -42,6 +42,7 @@ function init_db_schema(PDO $pdo): void { username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'admin', + is_enabled INTEGER NOT NULL DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); @@ -86,8 +87,31 @@ function init_db_schema(PDO $pdo): void { kitchen_close_time TEXT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); + + -- Single-row table (id is pinned to 1) for site-wide toggles that + -- don't belong to any one record — starting with whether the + -- public menu page shows its items at all. + CREATE TABLE IF NOT EXISTS site_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + menu_enabled INTEGER NOT NULL DEFAULT 1, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); "); + // users.is_enabled shipped after the initial release — CREATE TABLE IF + // NOT EXISTS above is a no-op against an existing table, so an install + // from before this needs the column added explicitly, once. + $hasIsEnabled = false; + foreach ($pdo->query("PRAGMA table_info(users)") as $col) { + if ($col['name'] === 'is_enabled') { + $hasIsEnabled = true; + break; + } + } + if (!$hasIsEnabled) { + $pdo->exec("ALTER TABLE users ADD COLUMN is_enabled INTEGER NOT NULL DEFAULT 1"); + } + // Seed default admin user if table is empty (username: admin / password: password) $stmt = $pdo->query("SELECT COUNT(*) as count FROM users"); $userCount = $stmt->fetchColumn(); @@ -125,4 +149,11 @@ function init_db_schema(PDO $pdo): void { } } } + + // Seed the singleton settings row (menu on by default) if it doesn't exist yet. + $stmt = $pdo->query("SELECT COUNT(*) as count FROM site_settings"); + $settingsCount = $stmt->fetchColumn(); + if ($settingsCount == 0) { + $pdo->exec("INSERT INTO site_settings (id, menu_enabled) VALUES (1, 1)"); + } } diff --git a/inc/menu.php b/inc/menu.php index 91fdf60..82298cc 100644 --- a/inc/menu.php +++ b/inc/menu.php @@ -7,6 +7,22 @@ declare(strict_types=1); require_once __DIR__ . '/db.php'; +// Whether the public menu page should show its items at all — separate +// from any individual item's is_available, this is the admin-facing "take +// the whole menu down for maintenance" switch. Defaults to enabled if the +// singleton settings row is somehow missing. +function is_menu_enabled(): bool { + $pdo = get_db(); + $value = $pdo->query("SELECT menu_enabled FROM site_settings WHERE id = 1")->fetchColumn(); + return $value === false ? true : (bool) $value; +} + +function set_menu_enabled(bool $enabled): void { + $pdo = get_db(); + $stmt = $pdo->prepare("UPDATE site_settings SET menu_enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1"); + $stmt->execute([$enabled ? 1 : 0]); +} + function get_full_menu(bool $availableOnly = false): array { $pdo = get_db(); diff --git a/inc/users.php b/inc/users.php index 669a06d..ccb765a 100644 --- a/inc/users.php +++ b/inc/users.php @@ -13,13 +13,13 @@ require_once __DIR__ . '/db.php'; function get_all_users(): array { $pdo = get_db(); - $stmt = $pdo->query("SELECT id, username, role, created_at FROM users ORDER BY username ASC"); + $stmt = $pdo->query("SELECT id, username, role, is_enabled, created_at FROM users ORDER BY username ASC"); return $stmt->fetchAll() ?: []; } function get_user_by_id(int $id): ?array { $pdo = get_db(); - $stmt = $pdo->prepare("SELECT id, username, role, created_at FROM users WHERE id = ?"); + $stmt = $pdo->prepare("SELECT id, username, role, is_enabled, created_at FROM users WHERE id = ?"); $stmt->execute([$id]); $res = $stmt->fetch(); return $res ?: null; @@ -30,6 +30,17 @@ function count_users(): int { return (int) $pdo->query("SELECT COUNT(*) FROM users")->fetchColumn(); } +function count_enabled_users(): int { + $pdo = get_db(); + return (int) $pdo->query("SELECT COUNT(*) FROM users WHERE is_enabled = 1")->fetchColumn(); +} + +function toggle_user_enabled(int $id): bool { + $pdo = get_db(); + $stmt = $pdo->prepare("UPDATE users SET is_enabled = CASE WHEN is_enabled = 1 THEN 0 ELSE 1 END WHERE id = ?"); + return $stmt->execute([$id]); +} + function username_exists(string $username, ?int $excludeId = null): bool { $pdo = get_db(); if ($excludeId !== null) { diff --git a/index.php b/index.php index b0a6d10..87cc6a2 100644 --- a/index.php +++ b/index.php @@ -84,7 +84,7 @@ $siteUrl = rtrim(SITE_URL, '/'); @@ -105,7 +105,7 @@ $siteUrl = rtrim(SITE_URL, '/');
- Traditional Irish PubFamily FriendlyNo Reservations + Traditional Irish PubFamily FriendlyNo Reservations
Get Directions diff --git a/main.js b/main.js index 8614554..a7ad522 100644 --- a/main.js +++ b/main.js @@ -64,3 +64,56 @@ closeNav(); }); })(); + +// Wrap-aware separator dots (announcement banner, hero tagline row). +// Hides a "·" only when its two flanking elements wrap onto different +// visual lines. There's no CSS way to detect "same line as sibling," and +// a fixed viewport breakpoint can't substitute for it either, since +// wrapping depends on the announcement's own text length, not just +// screen width. +(() => { + const seps = document.querySelectorAll('.wrap-sep'); + if (!seps.length) return; + + function sameLine(a, b) { + const ra = a.getBoundingClientRect(); + const rb = b.getBoundingClientRect(); + return ra.top < rb.bottom && rb.top < ra.bottom; + } + + // Hiding a separator removes it from flow (see .wrap-sep-hidden in + // styles.css — needs to, so a hidden dot never skews centering by still + // reserving its space), which means one separator's hide/show can shift + // where a *later* one's neighbors land — e.g. the tagline row's second + // dot needs to see the first dot's decision already applied, or it may + // judge its own neighbors' line-sharing from a layout that's since + // changed underneath it. So this deliberately processes separators one + // at a time, in DOM order, applying each decision before measuring the + // next: reset just this one to visible (so its own contribution is + // never argued away by whatever its own current hidden state already + // happens to be — otherwise "hidden" would be self-confirming, since a + // hidden dot's neighbors always look like they share a line), measure, + // decide, apply, then move on with that change already in effect. + function sync() { + seps.forEach((sep) => { + const prev = sep.previousElementSibling; + const next = sep.nextElementSibling; + if (!prev || !next) return; + + sep.classList.remove('wrap-sep-hidden'); + sep.classList.toggle('wrap-sep-hidden', !sameLine(prev, next)); + }); + } + + sync(); + + let resizeTimer; + window.addEventListener('resize', () => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(sync, 100); + }); + + if (document.fonts && document.fonts.ready) { + document.fonts.ready.then(sync); + } +})(); diff --git a/menu.php b/menu.php index 122606d..5cb36c4 100644 --- a/menu.php +++ b/menu.php @@ -7,7 +7,8 @@ require __DIR__ . '/inc/menu.php'; require __DIR__ . '/inc/hours.php'; $activeAnnouncements = get_active_announcements(); -$menuCategories = get_full_menu(true); // available items only +$menuEnabled = is_menu_enabled(); +$menuCategories = $menuEnabled ? get_full_menu(true) : []; // available items only, or none while the menu is disabled $hoursByDay = get_all_hours(); $siteUrl = rtrim(SITE_URL, '/'); ?> @@ -83,7 +84,7 @@ $siteUrl = rtrim(SITE_URL, '/'); @@ -109,7 +110,7 @@ $siteUrl = rtrim(SITE_URL, '/');