Add menu enable toggle, user status, and mobile/UI polish

- 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 <noreply@anthropic.com>
This commit is contained in:
David DyerandClaude Sonnet 5 committed 2026-09-01 14:10:08 -04:00
1 parent eab20899de
commit cd4c6c1969
13 files changed
+390 -35

No files matched your search

+2
View File
@@ -215,6 +215,7 @@ $announcements = get_all_announcements();
</div> </div>
<div data-label="Actions"> <div data-label="Actions">
<div class="row-actions"> <div class="row-actions">
<div class="action-stack">
<a href="index.php?edit=<?= (int)$ann['id'] ?>" class="btn btn-outline btn-sm">Edit</a> <a href="index.php?edit=<?= (int)$ann['id'] ?>" class="btn btn-outline btn-sm">Edit</a>
<form method="POST" action="index.php" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this announcement?');"> <form method="POST" action="index.php" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this announcement?');">
@@ -226,6 +227,7 @@ $announcements = get_all_announcements();
</div> </div>
</div> </div>
</div> </div>
</div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
</div> </div>
+42
View File
@@ -86,9 +86,51 @@ async function postForm(formData) {
return response.json(); 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 // Modal listeners
document.getElementById('btn-new-item').addEventListener('click', () => openItemModal()); document.getElementById('btn-new-item').addEventListener('click', () => openItemModal());
document.getElementById('btn-new-cat').addEventListener('click', () => openCategoryModal()); 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-create-first-cat')?.addEventListener('click', () => openCategoryModal());
document.getElementById('btn-close-item-modal').addEventListener('click', closeItemModal); document.getElementById('btn-close-item-modal').addEventListener('click', closeItemModal);
document.getElementById('btn-cancel-item').addEventListener('click', closeItemModal); document.getElementById('btn-cancel-item').addEventListener('click', closeItemModal);
+15
View File
@@ -135,6 +135,12 @@ function handle_delete_category(array $post): array
return ['success' => true]; 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 function handle_menu_action(string $action, array $post): array
{ {
return match ($action) { return match ($action) {
@@ -145,6 +151,7 @@ function handle_menu_action(string $action, array $post): array
'save_category' => handle_save_category($post), 'save_category' => handle_save_category($post),
'move_category' => handle_move_category($post), 'move_category' => handle_move_category($post),
'delete_category' => handle_delete_category($post), 'delete_category' => handle_delete_category($post),
'toggle_menu_enabled' => handle_toggle_menu_enabled(),
default => ['success' => false, 'error' => 'Unknown action'], default => ['success' => false, 'error' => 'Unknown action'],
}; };
} }
@@ -167,6 +174,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$categories = get_all_categories(); $categories = get_all_categories();
$menuGrouped = get_full_menu(false); $menuGrouped = get_full_menu(false);
$menuEnabled = is_menu_enabled();
?> ?>
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
@@ -213,6 +221,13 @@ $menuGrouped = get_full_menu(false);
<div class="admin-toolbar"> <div class="admin-toolbar">
<button type="button" class="btn btn-primary btn-sm" id="btn-new-item">+ Add Menu Item</button> <button type="button" class="btn btn-primary btn-sm" id="btn-new-item">+ Add Menu Item</button>
<button type="button" class="btn btn-outline btn-sm" id="btn-new-cat">+ Add Category</button> <button type="button" class="btn btn-outline btn-sm" id="btn-new-cat">+ Add Category</button>
<button type="button" class="btn btn-sm <?= $menuEnabled ? 'btn-danger' : 'btn-outline' ?>" id="btn-toggle-menu" data-enabled="<?= $menuEnabled ? '1' : '0' ?>">
<?= $menuEnabled ? 'Disable Menu' : 'Enable Menu' ?>
</button>
</div>
<div class="alert-error" id="menu-disabled-alert" role="status" <?= $menuEnabled ? 'hidden' : '' ?>>
Menu is disabled — the public menu page shows a "menu being updated" message instead of your items.
</div> </div>
<div id="categories-container"> <div id="categories-container">
+37 -3
View File
@@ -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) => { document.getElementById('users-rows').addEventListener('click', async (e) => {
const { target } = 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')) { if (target.classList.contains('btn-change-password')) {
openPasswordModal(target.dataset.id, target.dataset.username); openPasswordModal(target.dataset.id, target.dataset.username);
return; return;
@@ -197,14 +225,20 @@ function insertUserRow(user) {
// data-username is set via dataset below, never interpolated into the // data-username is set via dataset below, never interpolated into the
// HTML string — escapeHtml() only escapes for text-content positions, // HTML string — escapeHtml() only escapes for text-content positions,
// not attribute values, so it can't safely build an attribute string. // 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 = ` row.innerHTML = `
<div data-label="Status">
<button type="button" class="status-badge btn-toggle-user active" data-id="${user.id}" title="Click to mark as disabled">● Enabled</button>
</div>
<div data-label="Username"><strong>${escapeHtml(user.username)}</strong></div> <div data-label="Username"><strong>${escapeHtml(user.username)}</strong></div>
<div data-label="Role">${escapeHtml(user.role.charAt(0).toUpperCase() + user.role.slice(1))}</div> <div data-label="Role">${escapeHtml(user.role.charAt(0).toUpperCase() + user.role.slice(1))}</div>
<div class="font-mono timestamp-cell" data-label="Created">${escapeHtml(user.created_at)}</div> <div class="font-mono timestamp-cell" data-label="Created">${escapeHtml(user.created_at)}</div>
<div data-label="Actions"><div class="row-actions"> <div data-label="Actions"><div class="row-actions"><div class="action-stack">
<button type="button" class="btn btn-outline btn-sm btn-change-password" data-id="${user.id}">Change Password</button> <button type="button" class="btn btn-outline btn-sm btn-change-password" data-id="${user.id}">Change Password</button>
<button type="button" class="btn btn-danger btn-sm btn-delete-user" data-id="${user.id}">Delete</button> <button type="button" class="btn btn-danger btn-sm btn-delete-user" data-id="${user.id}">Delete</button>
</div></div> </div></div></div>
`; `;
row.querySelector('.btn-change-password').dataset.username = user.username; row.querySelector('.btn-change-password').dataset.username = user.username;
+45 -2
View File
@@ -93,6 +93,32 @@ function handle_change_password(array $post, array $currentUser): array {
return ['success' => true]; 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 { function handle_delete_user(array $post, array $currentUser): array {
$targetId = (int) ($post['user_id'] ?? 0); $targetId = (int) ($post['user_id'] ?? 0);
if ($targetId <= 0 || !get_user_by_id($targetId)) { 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) { return match ($action) {
'create_user' => handle_create_user($post, $currentUser), 'create_user' => handle_create_user($post, $currentUser),
'change_password' => handle_change_password($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), 'delete_user' => handle_delete_user($post, $currentUser),
default => ['success' => false, 'error' => 'Unknown action'], default => ['success' => false, 'error' => 'Unknown action'],
}; };
@@ -191,6 +218,7 @@ $users = get_all_users();
<div class="user-grid-wrap"> <div class="user-grid-wrap">
<div class="user-grid-head" aria-hidden="true"> <div class="user-grid-head" aria-hidden="true">
<span class="col-status">Status</span>
<span class="col-username">Username</span> <span class="col-username">Username</span>
<span class="col-role">Role</span> <span class="col-role">Role</span>
<span class="col-created">Created</span> <span class="col-created">Created</span>
@@ -198,10 +226,23 @@ $users = get_all_users();
</div> </div>
<div class="user-rows" id="users-rows"> <div class="user-rows" id="users-rows">
<?php foreach ($users as $row): ?> <?php foreach ($users as $row): ?>
<?php $isSelf = (int) $row['id'] === (int) $user['id']; ?>
<?php $isEnabled = (int) $row['is_enabled'] === 1; ?>
<div class="user-row" id="user-row-<?= (int) $row['id'] ?>" data-user-id="<?= (int) $row['id'] ?>"> <div class="user-row" id="user-row-<?= (int) $row['id'] ?>" data-user-id="<?= (int) $row['id'] ?>">
<div data-label="Status">
<?php if ($isSelf): ?>
<span class="status-badge <?= $isEnabled ? 'active' : 'inactive' ?>" title="You can't disable your own account.">
<?= $isEnabled ? '● Enabled' : '○ Disabled' ?>
</span>
<?php else: ?>
<button type="button" class="status-badge btn-toggle-user <?= $isEnabled ? 'active' : 'inactive' ?>" data-id="<?= (int) $row['id'] ?>" title="Click to mark as <?= $isEnabled ? 'disabled' : 'enabled' ?>">
<?= $isEnabled ? '● Enabled' : '○ Disabled' ?>
</button>
<?php endif; ?>
</div>
<div data-label="Username"> <div data-label="Username">
<strong><?= htmlspecialchars($row['username']) ?></strong> <strong><?= htmlspecialchars($row['username']) ?></strong>
<?php if ((int) $row['id'] === (int) $user['id']): ?> <?php if ($isSelf): ?>
<span class="muted"> (you)</span> <span class="muted"> (you)</span>
<?php endif; ?> <?php endif; ?>
</div> </div>
@@ -209,12 +250,13 @@ $users = get_all_users();
<div class="font-mono timestamp-cell" data-label="Created"><?= htmlspecialchars($row['created_at']) ?></div> <div class="font-mono timestamp-cell" data-label="Created"><?= htmlspecialchars($row['created_at']) ?></div>
<div data-label="Actions"> <div data-label="Actions">
<div class="row-actions"> <div class="row-actions">
<div class="action-stack">
<button type="button" class="btn btn-outline btn-sm btn-change-password" <button type="button" class="btn btn-outline btn-sm btn-change-password"
data-id="<?= (int) $row['id'] ?>" data-id="<?= (int) $row['id'] ?>"
data-username="<?= htmlspecialchars($row['username']) ?>"> data-username="<?= htmlspecialchars($row['username']) ?>">
Change Password Change Password
</button> </button>
<?php if ((int) $row['id'] !== (int) $user['id']): ?> <?php if (!$isSelf): ?>
<button type="button" class="btn btn-danger btn-sm btn-delete-user" data-id="<?= (int) $row['id'] ?>"> <button type="button" class="btn btn-danger btn-sm btn-delete-user" data-id="<?= (int) $row['id'] ?>">
Delete Delete
</button> </button>
@@ -222,6 +264,7 @@ $users = get_all_users();
</div> </div>
</div> </div>
</div> </div>
</div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
</div> </div>
+14 -1
View File
@@ -25,6 +25,19 @@ function require_auth(): void {
header('Location: login.php'); header('Location: login.php');
exit; 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 { function get_logged_in_user(): ?array {
@@ -44,7 +57,7 @@ function authenticate_user(string $username, string $password): bool {
$stmt->execute([trim($username)]); $stmt->execute([trim($username)]);
$user = $stmt->fetch(); $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_regenerate_id(true);
$_SESSION['admin_user_id'] = $user['id']; $_SESSION['admin_user_id'] = $user['id'];
$_SESSION['admin_username'] = $user['username']; $_SESSION['admin_username'] = $user['username'];
+31
View File
@@ -42,6 +42,7 @@ function init_db_schema(PDO $pdo): void {
username TEXT UNIQUE NOT NULL, username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'admin', role TEXT NOT NULL DEFAULT 'admin',
is_enabled INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
@@ -86,8 +87,31 @@ function init_db_schema(PDO $pdo): void {
kitchen_close_time TEXT NULL, kitchen_close_time TEXT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP 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) // Seed default admin user if table is empty (username: admin / password: password)
$stmt = $pdo->query("SELECT COUNT(*) as count FROM users"); $stmt = $pdo->query("SELECT COUNT(*) as count FROM users");
$userCount = $stmt->fetchColumn(); $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)");
}
} }
+16
View File
@@ -7,6 +7,22 @@ declare(strict_types=1);
require_once __DIR__ . '/db.php'; 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 { function get_full_menu(bool $availableOnly = false): array {
$pdo = get_db(); $pdo = get_db();
+13 -2
View File
@@ -13,13 +13,13 @@ require_once __DIR__ . '/db.php';
function get_all_users(): array { function get_all_users(): array {
$pdo = get_db(); $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() ?: []; return $stmt->fetchAll() ?: [];
} }
function get_user_by_id(int $id): ?array { function get_user_by_id(int $id): ?array {
$pdo = get_db(); $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]); $stmt->execute([$id]);
$res = $stmt->fetch(); $res = $stmt->fetch();
return $res ?: null; return $res ?: null;
@@ -30,6 +30,17 @@ function count_users(): int {
return (int) $pdo->query("SELECT COUNT(*) FROM users")->fetchColumn(); 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 { function username_exists(string $username, ?int $excludeId = null): bool {
$pdo = get_db(); $pdo = get_db();
if ($excludeId !== null) { if ($excludeId !== null) {
+2 -2
View File
@@ -84,7 +84,7 @@ $siteUrl = rtrim(SITE_URL, '/');
<div class="announcement-banner banner-<?= htmlspecialchars($banner['type']) ?>" role="region" aria-label="Important Announcement"> <div class="announcement-banner banner-<?= htmlspecialchars($banner['type']) ?>" role="region" aria-label="Important Announcement">
<div class="container announcement-inner"> <div class="container announcement-inner">
<span class="announcement-tag"><?= htmlspecialchars($banner['headline']) ?></span> <span class="announcement-tag"><?= htmlspecialchars($banner['headline']) ?></span>
<span class="announcement-sep" aria-hidden="true">·</span> <span class="announcement-sep wrap-sep" aria-hidden="true">·</span>
<span class="announcement-text"><?= htmlspecialchars($banner['message']) ?></span> <span class="announcement-text"><?= htmlspecialchars($banner['message']) ?></span>
</div> </div>
</div> </div>
@@ -105,7 +105,7 @@ $siteUrl = rtrim(SITE_URL, '/');
<!-- <h1>The Old Fitz</h1> --> <!-- <h1>The Old Fitz</h1> -->
<!-- <p class="sub">Public House</p> --> <!-- <p class="sub">Public House</p> -->
<div class="tagline-row"> <div class="tagline-row">
<span>Traditional Irish Pub</span><span>Family Friendly</span><span>No Reservations</span> <span>Traditional Irish Pub</span><span class="tag-sep wrap-sep" aria-hidden="true">·</span><span>Family Friendly</span><span class="tag-sep wrap-sep" aria-hidden="true">·</span><span>No Reservations</span>
</div> </div>
<div class="hero-ctas"> <div class="hero-ctas">
<a class="btn btn-primary" href="https://www.google.com/maps/search/?api=1&query=100+N+Washington+St+Marion+IN" target="_blank" rel="noopener">Get Directions</a> <a class="btn btn-primary" href="https://www.google.com/maps/search/?api=1&query=100+N+Washington+St+Marion+IN" target="_blank" rel="noopener">Get Directions</a>
+53
View File
@@ -64,3 +64,56 @@
closeNav(); 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);
}
})();
+4 -3
View File
@@ -7,7 +7,8 @@ require __DIR__ . '/inc/menu.php';
require __DIR__ . '/inc/hours.php'; require __DIR__ . '/inc/hours.php';
$activeAnnouncements = get_active_announcements(); $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(); $hoursByDay = get_all_hours();
$siteUrl = rtrim(SITE_URL, '/'); $siteUrl = rtrim(SITE_URL, '/');
?> ?>
@@ -83,7 +84,7 @@ $siteUrl = rtrim(SITE_URL, '/');
<div class="announcement-banner banner-<?= htmlspecialchars($banner['type']) ?>" role="region" aria-label="Important Announcement"> <div class="announcement-banner banner-<?= htmlspecialchars($banner['type']) ?>" role="region" aria-label="Important Announcement">
<div class="container announcement-inner"> <div class="container announcement-inner">
<span class="announcement-tag"><?= htmlspecialchars($banner['headline']) ?></span> <span class="announcement-tag"><?= htmlspecialchars($banner['headline']) ?></span>
<span class="announcement-sep" aria-hidden="true">·</span> <span class="announcement-sep wrap-sep" aria-hidden="true">·</span>
<span class="announcement-text"><?= htmlspecialchars($banner['message']) ?></span> <span class="announcement-text"><?= htmlspecialchars($banner['message']) ?></span>
</div> </div>
</div> </div>
@@ -109,7 +110,7 @@ $siteUrl = rtrim(SITE_URL, '/');
<section class="menu-page-content" style="padding-top: 0;"> <section class="menu-page-content" style="padding-top: 0;">
<div class="container" style="max-width: 62rem;"> <div class="container" style="max-width: 62rem;">
<?php if (!empty($menuCategories)): ?> <?php if ($menuEnabled && !empty($menuCategories)): ?>
<div class="menu-grid" style="margin-top: 0;"> <div class="menu-grid" style="margin-top: 0;">
<?php foreach ($menuCategories as $category): ?> <?php foreach ($menuCategories as $category): ?>
<?php if (!empty($category['items'])): ?> <?php if (!empty($category['items'])): ?>
+116 -22
View File
@@ -34,6 +34,26 @@
} }
} }
/* ---------- wrap-aware separators (JS-toggled, see main.js) ----------
Shared hook used by the announcement banner and hero tagline row: a
"·" between two pieces of text that can independently wrap onto
separate lines. No CSS selector exists for "am I on the same line as
my sibling," so main.js measures and toggles this class instead of a
fixed breakpoint. Visible by default so a no-JS visitor always sees
the dot (today's behavior), never hidden by default.
display, not visibility: a hidden separator must take no space, or
it still gets counted when justify-content: center balances its flex
line — a lone badge sharing its line with a space-reserving invisible
dot renders off-center. Safe from the "text merges together" bug that
originally motivated reserving space: both wrap-sep contexts space
their siblings with a real flex `gap` (see .announcement-inner,
.tagline-row) rather than the separator's own margin, so removing it
from flow still leaves normal spacing behind, never a collision. */
.wrap-sep-hidden {
display: none;
}
body { body {
margin: 0; margin: 0;
background: var(--ground); background: var(--ground);
@@ -478,17 +498,28 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.6rem 0; /* column-gap carries the spacing now (was 0 — the dot's own margin
used to be the only thing separating the phrases). Needs to be a
real flex gap, not a margin on the dot, so removing a hidden dot
from flow (see .wrap-sep-hidden) still leaves normal spacing
between the two phrases instead of running them together. */
gap: 0.6rem 0.7rem;
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 0.82rem; font-size: 0.82rem;
letter-spacing: 0.04em; letter-spacing: 0.04em;
color: var(--ink-muted); color: var(--ink-muted);
} }
.tagline-row span:not(:last-child)::after { /* Real element instead of a ::after pseudo-element — needs to be a
content: "·"; measurable, independently-toggleable DOM node for the wrap-aware
script in main.js to hide it when its neighbors wrap apart. No margin
of its own: the column-gap above already provides 0.7rem on each
side of it (matching the old margin-inline's total spacing exactly),
and still applies directly between the two phrases whenever the dot
itself is removed from flow. */
.tag-sep {
color: var(--gold-dim); color: var(--gold-dim);
margin-inline: 0.7rem; user-select: none;
} }
.hero-ctas { .hero-ctas {
@@ -502,6 +533,11 @@
.btn { .btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
/* Centers label text horizontally too — matters once a button is
stretched wider than its content (e.g. action-stack children filling
a fixed-width column) or wraps to multiple lines; a no-op otherwise,
so every button gets this instead of one-off overrides per case. */
justify-content: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.8rem 1.5rem; padding: 0.8rem 1.5rem;
font-family: var(--font-mono); font-family: var(--font-mono);
@@ -1330,6 +1366,14 @@
width: 7.25rem; width: 7.25rem;
} }
/* "○ Disabled" is the longer of the two user labels. Not scoped to
button.status-badge like the two above — your own row's badge is a
plain non-interactive span (see users.php), and it still needs to line
up in the same column. */
.user-row .status-badge {
width: 7rem;
}
/* Admin row actions: the button-group styling shared by every admin /* Admin row actions: the button-group styling shared by every admin
list (menu items/categories, announcements, users). The actual list (menu items/categories, announcements, users). The actual
row/column layout for each list is its own CSS Grid, defined where row/column layout for each list is its own CSS Grid, defined where
@@ -1576,7 +1620,7 @@
} }
.user-grid-wrap { .user-grid-wrap {
grid-template-columns: minmax(0, 1fr) auto auto auto; grid-template-columns: auto minmax(0, 1fr) auto auto auto;
} }
.item-grid-wrap, .item-grid-wrap,
@@ -1640,12 +1684,13 @@
} }
.user-grid-head, .user-row { .user-grid-head, .user-row {
grid-template-areas: "username role created actions"; grid-template-areas: "status username role created actions";
} }
.col-status, .col-status,
.item-row [data-label="Status"], .item-row [data-label="Status"],
.ann-row [data-label="Status"] { .ann-row [data-label="Status"],
.user-row [data-label="Status"] {
grid-area: status; grid-area: status;
} }
@@ -1726,10 +1771,37 @@
columns: subgrid` from the desktop rule, so this one change is all columns: subgrid` from the desktop rule, so this one change is all
it takes to reflow the whole list (see grid-template-areas per row it takes to reflow the whole list (see grid-template-areas per row
further down for how each field then stacks within that column). */ further down for how each field then stacks within that column). */
/* Every list's cards get a second column on mobile so Actions can sit
in the top-right corner alongside that list's leading field, instead
of stacking at the very bottom of the card — overrides the shared
single-column rule that would otherwise apply here too.
minmax(0, auto), not plain auto, so this column can still shrink
below its content's natural width under pressure instead of forcing
the whole row to overflow the card. */
.item-grid-wrap, .item-grid-wrap,
.ann-grid-wrap, .ann-grid-wrap,
.user-grid-wrap { .user-grid-wrap {
grid-template-columns: 1fr; grid-template-columns: 1fr minmax(0, auto);
}
/* Users' actions column gets a fixed cap instead of auto: "Change
Password" is long enough that its max-content size (what auto would
size the column to) is wider than this corner has room for, which
pushed the button past the card edge. A firm cap plus the
wrap/stretch rules below keep it predictable instead. */
.user-grid-wrap {
grid-template-columns: minmax(0, 1fr) 6rem;
}
/* Every row's Actions cell holds one or two .action-stacks (move
up/down, edit/delete, etc. — each stacked vertically, see
.action-stack itself further up) side by side. Keeps those stacks
from wrapping onto their own lines in this narrow corner, which is
what the shared .row-actions wrap rule below would otherwise do. */
.item-row .row-actions,
.ann-row .row-actions,
.user-row .row-actions {
flex-wrap: nowrap;
} }
.item-grid-head, .item-grid-head,
@@ -1770,28 +1842,50 @@
.item-row { .item-row {
grid-template-areas: grid-template-areas:
"status" "status actions"
"item" "item item"
"price" "price price";
"actions"; /* Overrides the shared align-items: center above — on desktop that
vertically centers Status against the taller Item cell in one wide
row, which is fine, but here it also centers Status against the
taller Actions block above, pushing its badge down instead of
keeping both flush with the card's top edge. */
align-items: start;
} }
.ann-row { .ann-row {
grid-template-areas: grid-template-areas:
"status" "status actions"
"headline" "headline headline"
"type" "type type"
"schedule" "schedule schedule"
"placements" "placements placements";
"actions"; align-items: start;
} }
.user-row { .user-row {
grid-template-areas: grid-template-areas:
"username" "status actions"
"role" "username username"
"created" "role role"
"actions"; "created created";
align-items: start;
}
/* Stretches Actions' whole flex chain out to the fixed 6rem column
above (each is shrink-wrapped by default), then lets "Change
Password" — long enough on its own to blow out this corner next to a
username — wrap onto two lines within that width instead of forcing
the column wider. Every other action-stack button here (Edit,
Delete, ↑/↓) is short enough to stay on one line regardless. */
.user-row .row-actions,
.user-row .action-stack,
.user-row .row-actions .btn {
width: 100%;
}
.user-row .btn-change-password {
white-space: normal;
} }
.category-panel .panel-head { .category-panel .panel-head {