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
+405 -50

No files matched your search

+9 -7
View File
@@ -215,14 +215,16 @@ $announcements = get_all_announcements();
</div>
<div data-label="Actions">
<div class="row-actions">
<a href="index.php?edit=<?= (int)$ann['id'] ?>" class="btn btn-outline btn-sm">Edit</a>
<div class="action-stack">
<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?');">
<input type="hidden" name="action" value="delete_announcement">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
<input type="hidden" name="announcement_id" value="<?= (int)$ann['id'] ?>">
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
<form method="POST" action="index.php" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this announcement?');">
<input type="hidden" name="action" value="delete_announcement">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
<input type="hidden" name="announcement_id" value="<?= (int)$ann['id'] ?>">
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
</div>
</div>
</div>
</div>
+42
View File
@@ -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);
+15
View File
@@ -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();
?>
<!doctype html>
<html lang="en">
@@ -213,6 +221,13 @@ $menuGrouped = get_full_menu(false);
<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-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 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) => {
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 = `
<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="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 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-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;
+53 -10
View File
@@ -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();
<div class="user-grid-wrap">
<div class="user-grid-head" aria-hidden="true">
<span class="col-status">Status</span>
<span class="col-username">Username</span>
<span class="col-role">Role</span>
<span class="col-created">Created</span>
@@ -198,10 +226,23 @@ $users = get_all_users();
</div>
<div class="user-rows" id="users-rows">
<?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 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">
<strong><?= htmlspecialchars($row['username']) ?></strong>
<?php if ((int) $row['id'] === (int) $user['id']): ?>
<?php if ($isSelf): ?>
<span class="muted"> (you)</span>
<?php endif; ?>
</div>
@@ -209,16 +250,18 @@ $users = get_all_users();
<div class="font-mono timestamp-cell" data-label="Created"><?= htmlspecialchars($row['created_at']) ?></div>
<div data-label="Actions">
<div class="row-actions">
<button type="button" class="btn btn-outline btn-sm btn-change-password"
data-id="<?= (int) $row['id'] ?>"
data-username="<?= htmlspecialchars($row['username']) ?>">
Change Password
</button>
<?php if ((int) $row['id'] !== (int) $user['id']): ?>
<button type="button" class="btn btn-danger btn-sm btn-delete-user" data-id="<?= (int) $row['id'] ?>">
Delete
<div class="action-stack">
<button type="button" class="btn btn-outline btn-sm btn-change-password"
data-id="<?= (int) $row['id'] ?>"
data-username="<?= htmlspecialchars($row['username']) ?>">
Change Password
</button>
<?php endif; ?>
<?php if (!$isSelf): ?>
<button type="button" class="btn btn-danger btn-sm btn-delete-user" data-id="<?= (int) $row['id'] ?>">
Delete
</button>
<?php endif; ?>
</div>
</div>
</div>
</div>