Add pub hours admin panel, rebuild announcements/users lists on CSS Grid

- Add a Hours admin panel (admin/hours.php, admin/hours.js) for editing
  the weekly open/close schedule and an optional per-day kitchen closing
  time, with a live preview of how it'll read on the public site
- Store hours in a new pub_hours table (inc/db.php), seeded with the
  site's existing Thu-Sat/9pm-kitchen schedule; inc/hours.php formats
  and groups it for display (day ranges, kitchen-close note, footer
  summary)
- Wire the public Visit card and both site footers (index.php, menu.php)
  to read hours from that data instead of hardcoded copy
- Rebuild the Announcements and Users admin tables onto the same CSS
  Grid list pattern already used for the Menu (admin-table-actions ->
  row-actions), and make the Announcements status toggle an in-place
  AJAX action like the menu item's (admin/index.js)
- Add the Hours tab to admin nav across all four admin pages

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
David DyerandClaude Sonnet 5 committed 2026-09-01 11:18:23 -04:00
1 parent fd928a24cf
commit eab20899de
14 files changed
+1224 -441

No files matched your search

+1
View File
@@ -15,3 +15,4 @@ db/fitz.sqlite-shm
.hintrc
dev.sh
.claude/
+18
View File
@@ -0,0 +1,18 @@
// Hours admin page: purely a progressive enhancement over the plain form
// POST that actually saves — greys out (and stops submitting) a day's time
// fields while its "Open" box is unchecked, so a closed day can't look like
// it still has stale hours set.
document.querySelectorAll('.hours-day-row').forEach((row) => {
const toggle = row.querySelector('input[type="checkbox"]');
const timeFields = row.querySelectorAll('.day-time-field');
function applyState() {
timeFields.forEach((field) => {
field.disabled = !toggle.checked;
});
}
toggle.addEventListener('change', applyState);
applyState();
});
+205
View File
@@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../inc/auth.php';
require_once __DIR__ . '/../inc/hours.php';
require_auth();
$user = get_logged_in_user();
$errorMsg = '';
/**
* Reads a day's submitted fields back into the same shape get_all_hours()
* rows use, so the form can re-render exactly what was typed (rather than
* falling back to the last-saved DB values) when validation fails.
*/
function hours_day_from_post(array $dayData): array {
return [
'is_open' => !empty($dayData['is_open']) ? 1 : 0,
'open_time' => normalize_time_value($dayData['open_time'] ?? null),
'close_time' => normalize_time_value($dayData['close_time'] ?? null),
'kitchen_close_time' => normalize_time_value($dayData['kitchen_close_time'] ?? null),
];
}
/**
* A day marked "Open" needs both ends of its range set — everything else
* (closed days, the optional kitchen closing time) is fine left blank.
*/
function validate_hours_input(array $daysInput): ?string {
for ($d = 0; $d <= 6; $d++) {
$day = hours_day_from_post(is_array($daysInput[$d] ?? null) ? $daysInput[$d] : []);
if ($day['is_open'] && (!$day['open_time'] || !$day['close_time'])) {
return HOURS_DAY_NAMES[$d] . ' is marked open but is missing an opening or closing time.';
}
}
return null;
}
$submittedHours = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$errorMsg = 'Session expired or invalid security token. Please try again.';
} elseif (($_POST['action'] ?? '') === 'save_hours') {
$daysInput = is_array($_POST['day'] ?? null) ? $_POST['day'] : [];
if ($error = validate_hours_input($daysInput)) {
$errorMsg = $error;
$submittedHours = [];
for ($d = 0; $d <= 6; $d++) {
$submittedHours[$d] = hours_day_from_post(is_array($daysInput[$d] ?? null) ? $daysInput[$d] : []);
}
} else {
save_all_hours($daysInput);
header('Location: hours.php?saved=1');
exit;
}
}
}
$hoursByDay = $submittedHours ?? get_all_hours();
$justSaved = !$_POST && isset($_GET['saved']);
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hours | The Old Fitz Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,600;1,9..144,400&family=Karla:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../styles.css">
</head>
<body>
<nav class="admin-nav">
<div class="container admin-nav-inner">
<a href="index.php" class="admin-brand">
<img src="../assets/knot.png" alt="" class="knot">
<span>The Old Fitz Admin</span>
</a>
<div class="admin-nav-links">
<a href="index.php">Announcements</a>
<a href="menu.php">Menu</a>
<a href="users.php">Users</a>
<a href="hours.php" class="active">Hours</a>
</div>
<div class="admin-user">
<span>Logged in as <strong><?= htmlspecialchars($user['username']) ?></strong></span>
<a href="../" target="_blank" rel="noopener">View Public Site ↗</a>
<a href="logout.php">Sign Out</a>
</div>
</div>
</nav>
<main class="dashboard-layout">
<div class="container">
<div class="dash-header">
<p class="eyebrow">Operations</p>
<h1>Pub Hours</h1>
</div>
<?php if (!empty($errorMsg)): ?>
<div class="alert-error" role="alert"><?= htmlspecialchars($errorMsg) ?></div>
<?php elseif ($justSaved): ?>
<div class="alert-success" role="status">Hours saved — the public site is now showing the updated schedule.</div>
<?php endif; ?>
<div class="panel">
<div class="panel-head">
<h2>
<span>🕒 Weekly Schedule</span>
</h2>
</div>
<form method="POST" action="hours.php">
<input type="hidden" name="action" value="save_hours">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
<div class="hours-grid-wrap">
<div class="hours-grid-head" aria-hidden="true">
<span class="col-day">Day</span>
<span class="col-hopen">Open</span>
<span class="col-hopens">Opens</span>
<span class="col-hcloses">Closes</span>
<span class="col-hkitchen">Kitchen Closes</span>
</div>
<div class="hours-day-rows">
<?php for ($d = 0; $d <= 6; $d++): ?>
<?php $day = $hoursByDay[$d]; ?>
<div class="hours-day-row" data-day="<?= $d ?>">
<div data-label="Day"><strong><?= HOURS_DAY_NAMES[$d] ?></strong></div>
<div data-label="Open">
<label class="checkbox-label day-open-toggle">
<input type="checkbox" name="day[<?= $d ?>][is_open]" value="1" <?= !empty($day['is_open']) ? 'checked' : '' ?>>
Open
</label>
</div>
<div data-label="Opens">
<input type="time" name="day[<?= $d ?>][open_time]" class="form-control day-time-field" value="<?= htmlspecialchars($day['open_time'] ?? '') ?>">
</div>
<div data-label="Closes">
<input type="time" name="day[<?= $d ?>][close_time]" class="form-control day-time-field" value="<?= htmlspecialchars($day['close_time'] ?? '') ?>">
</div>
<div data-label="Kitchen Closes">
<input type="time" name="day[<?= $d ?>][kitchen_close_time]" class="form-control day-time-field" value="<?= htmlspecialchars($day['kitchen_close_time'] ?? '') ?>">
</div>
</div>
<?php endfor; ?>
</div>
</div>
<p class="muted field-hint" style="margin-top: 1.25rem;">Leave "Kitchen Closes" blank on a day if the kitchen doesn't have a separate cutoff from the bar. Special closures (holidays, weather) are handled on the <a href="index.php">Announcements</a> page instead of here.</p>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save Hours</button>
</div>
</form>
</div>
<div class="panel">
<div class="panel-head">
<h2>
<span>👀 Public Site Preview</span>
</h2>
</div>
<p class="muted" style="margin-bottom: 1.25rem;">This is how the schedule above will read on the site once saved.</p>
<div class="hours-preview">
<div class="hours-preview-card">
<dl class="hours-list">
<?php foreach (hours_card_rows($hoursByDay) as $row): ?>
<div class="hours-row">
<dt><?= htmlspecialchars($row['label']) ?></dt>
<dd><?= htmlspecialchars($row['text']) ?></dd>
</div>
<?php endforeach; ?>
</dl>
<?php $kitchenNote = hours_kitchen_note($hoursByDay); ?>
<p class="note">
<?php if ($kitchenNote): ?><strong><?= htmlspecialchars($kitchenNote) ?></strong> — bar snacks stay out later. <?php endif; ?>Holiday hours may vary; check our Facebook page for the latest.
</p>
</div>
<div class="hours-preview-footer">
<p class="col-title">Hours</p>
<div class="hours-mini">
<?php foreach (hours_footer_lines($hoursByDay) as $line): ?>
<span><?= htmlspecialchars($line) ?></span>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
</div>
</main>
<script src="hours.js" defer></script>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
// Announcements admin page: intercepts the status badge's toggle form so
// clicking it updates in place via fetch instead of reloading the whole
// page — same interaction as the menu item's status toggle. Everything
// else here (create/edit/delete) is still a plain form POST + reload.
document.addEventListener('submit', async (e) => {
const form = e.target;
if (!form.classList.contains('status-toggle-form')) return;
e.preventDefault();
const btn = form.querySelector('button');
btn.disabled = true;
try {
const response = await fetch('index.php', {
method: 'POST',
body: new FormData(form),
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await response.json();
if (data.success) {
btn.className = `status-badge ${data.status_class}`;
btn.textContent = data.status_label;
btn.title = `Click to turn ${data.is_active ? 'off' : 'on'}`;
} else {
alert(data.error || 'Failed to update status.');
}
} catch {
alert('Network error. Please try again.');
} finally {
btn.disabled = false;
}
});
+219 -186
View File
@@ -5,12 +5,40 @@ require_once __DIR__ . '/../inc/announcements.php';
require_auth();
$user = get_logged_in_user();
$successMsg = '';
$errorMsg = '';
$today = date('Y-m-d');
// Handle Actions (Save, Delete, Toggle)
/**
* Whether an announcement is actually showing right now — active flag on
* AND within its optional date window. Matches the currently-live check
* used when rendering the list below.
*/
function announcement_is_live(array $ann, string $today): bool
{
return (bool) $ann['is_active']
&& (empty($ann['start_date']) || $ann['start_date'] <= $today)
&& (empty($ann['end_date']) || $ann['end_date'] >= $today);
}
// Handle Actions (Save, Delete, Toggle). Success isn't announced with a
// banner — the list itself is the feedback (the row appears/updates/
// disappears, the status badge flips) — same as the menu admin, and it
// avoids a banner shoving the whole page down every time someone clicks
// the status badge.
//
// The status toggle specifically is also fetched via AJAX (see index.js)
// so clicking it doesn't reload the page, matching the menu item's status
// toggle — everything else here (save/delete) is still a plain form POST.
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$isAjax = ($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest';
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8');
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Session expired or invalid security token. Please refresh.']);
exit;
}
$errorMsg = 'Session expired or invalid security token. Please try again.';
} else {
$action = $_POST['action'] ?? '';
@@ -27,19 +55,34 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'show_banner' => isset($_POST['show_banner']) ? 1 : 0,
'show_in_hours' => isset($_POST['show_in_hours']) ? 1 : 0,
], $annId);
$successMsg = $annId ? 'Announcement updated successfully!' : 'New announcement created successfully!';
} elseif ($action === 'delete_announcement') {
$annId = (int)($_POST['announcement_id'] ?? 0);
if ($annId > 0) {
delete_announcement($annId);
$successMsg = 'Announcement deleted successfully.';
}
} elseif ($action === 'toggle_active') {
$annId = (int)($_POST['announcement_id'] ?? 0);
if ($annId > 0) {
toggle_announcement_active($annId);
$successMsg = 'Status updated.';
}
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8');
$ann = $annId > 0 ? get_announcement_by_id($annId) : null;
if (!$ann) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Announcement not found.']);
exit;
}
$isLive = announcement_is_live($ann, $today);
echo json_encode([
'success' => true,
'is_active' => (bool) $ann['is_active'],
'status_class' => $isLive ? 'active' : 'inactive',
'status_label' => $isLive ? '● Live' : ($ann['is_active'] ? '⏳ Scheduled' : '○ Off'),
]);
exit;
}
}
}
@@ -55,7 +98,6 @@ if ($editingId) {
}
$announcements = get_all_announcements();
$today = date('Y-m-d');
?>
<!doctype html>
<html lang="en">
@@ -80,6 +122,7 @@ $today = date('Y-m-d');
<a href="index.php" class="active">Announcements</a>
<a href="menu.php">Menu</a>
<a href="users.php">Users</a>
<a href="hours.php">Hours</a>
</div>
<div class="admin-user">
<span>Logged in as <strong><?= htmlspecialchars($user['username']) ?></strong></span>
@@ -96,193 +139,183 @@ $today = date('Y-m-d');
<h1>Site Announcements & Alerts</h1>
</div>
<?php if (!empty($successMsg)): ?>
<div class="alert-success" role="status"><?= htmlspecialchars($successMsg) ?></div>
<?php endif; ?>
<?php if (!empty($errorMsg)): ?>
<div class="alert-error" role="alert"><?= htmlspecialchars($errorMsg) ?></div>
<?php endif; ?>
<?php if ($isCreatingNew || $editItem): ?>
<!-- Announcement Editor Form (Create / Edit) -->
<form method="POST" action="index.php">
<input type="hidden" name="action" value="save_announcement">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
<input type="hidden" name="announcement_id" value="<?= (int)($editItem['id'] ?? 0) ?>">
<div class="admin-toolbar">
<a href="index.php?new=1" class="btn btn-primary btn-sm">+ Create Announcement</a>
</div>
<div class="panel">
<div class="panel-head">
<h2>
<span><?= $editItem ? '✏️ Edit Announcement' : '✨ New Announcement' ?></span>
</h2>
<a href="index.php" class="btn btn-outline btn-sm">← Back to List</a>
</div>
<div class="form-grid">
<div class="form-group full-width">
<label class="checkbox-label checkbox-label-lg">
<input type="checkbox" name="is_active" value="1" <?= (!empty($editItem['is_active']) || $isCreatingNew) ? 'checked' : '' ?>>
<strong>Enable Announcement on Site</strong>
</label>
</div>
<div class="form-group">
<label for="headline">Notice Headline</label>
<input type="text" id="headline" name="headline" class="form-control"
value="<?= htmlspecialchars($editItem['headline'] ?? '') ?>"
placeholder="e.g. Labor Day Closure" required>
</div>
<div class="form-group">
<label for="type">Style / Category</label>
<select id="type" name="type" class="form-control">
<option value="holiday" <?= ($editItem['type'] ?? '') === 'holiday' ? 'selected' : '' ?>>Holiday / Special Schedule (Warm Brass & Green)</option>
<option value="special" <?= ($editItem['type'] ?? '') === 'special' ? 'selected' : '' ?>>Event / Live Music Announcement</option>
<option value="closure" <?= ($editItem['type'] ?? '') === 'closure' ? 'selected' : '' ?>>Urgent Closure / Weather Alert</option>
</select>
</div>
<div class="form-group full-width">
<label for="message">Notice Message</label>
<textarea id="message" name="message" class="form-control" required
placeholder="Write details of closure, dates, and when regular hours resume..."><?= htmlspecialchars($editItem['message'] ?? '') ?></textarea>
</div>
<div class="form-group">
<label for="start_date">Start Date (Optional Auto-Schedule)</label>
<input type="date" id="start_date" name="start_date" class="form-control"
value="<?= htmlspecialchars($editItem['start_date'] ?? '') ?>">
<span class="muted font-mono" style="font-size: 0.78rem;">Leave blank to show immediately while enabled.</span>
</div>
<div class="form-group">
<label for="end_date">End Date (Optional Expiration)</label>
<input type="date" id="end_date" name="end_date" class="form-control"
value="<?= htmlspecialchars($editItem['end_date'] ?? '') ?>">
<span class="muted font-mono" style="font-size: 0.78rem;">Auto-hides after 11:59 PM on this date.</span>
</div>
<div class="form-group full-width">
<label>Display Placements</label>
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" name="show_banner" value="1" <?= (!isset($editItem) || !empty($editItem['show_banner'])) ? 'checked' : '' ?>>
Show as top alert banner across the site
</label>
<label class="checkbox-label">
<input type="checkbox" name="show_in_hours" value="1" <?= (!isset($editItem) || !empty($editItem['show_in_hours'])) ? 'checked' : '' ?>>
Highlight inside the "Hours &amp; Visit" card
</label>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save Announcement</button>
<a href="index.php" class="btn btn-outline">Cancel</a>
</div>
</div>
</form>
<?php else: ?>
<!-- Announcements List View -->
<div class="panel">
<div class="panel-head">
<h2>
<span>📢 All Announcements (<?= count($announcements) ?>)</span>
</h2>
<a href="index.php?new=1" class="btn btn-primary btn-sm">+ Create Announcement</a>
</div>
<?php if (empty($announcements)): ?>
<div class="empty-state">
<p>No announcements created yet.</p>
<a href="index.php?new=1" class="btn btn-primary btn-sm">+ Create Your First Announcement</a>
</div>
<?php else: ?>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>Status</th>
<th>Headline</th>
<th>Type</th>
<th>Schedule</th>
<th>Placements</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($announcements as $ann): ?>
<?php
$isCurrentlyActive = $ann['is_active'] &&
(empty($ann['start_date']) || $ann['start_date'] <= $today) &&
(empty($ann['end_date']) || $ann['end_date'] >= $today);
?>
<tr>
<td data-label="Status">
<?php if ($isCurrentlyActive): ?>
<span class="status-badge active">● Live</span>
<?php elseif ($ann['is_active']): ?>
<span class="status-badge inactive">⏳ Scheduled</span>
<?php else: ?>
<span class="status-badge inactive">○ Off</span>
<?php endif; ?>
</td>
<td data-label="Headline">
<strong><?= htmlspecialchars($ann['headline']) ?></strong>
<div class="muted" style="font-size: 0.82rem; margin-top: 0.2rem; max-width: 32ch; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
<?= htmlspecialchars($ann['message']) ?>
</div>
</td>
<td data-label="Type">
<span class="eyebrow"><?= htmlspecialchars($ann['type']) ?></span>
</td>
<td class="font-mono" data-label="Schedule" style="font-size: 0.8rem; color: var(--ink-muted);">
<?php if (!empty($ann['start_date']) || !empty($ann['end_date'])): ?>
<?= htmlspecialchars($ann['start_date'] ?: 'Now') ?> → <?= htmlspecialchars($ann['end_date'] ?: 'Ongoing') ?>
<?php else: ?>
Always (while on)
<?php endif; ?>
</td>
<td data-label="Placements" style="font-size: 0.82rem; color: var(--ink-muted);">
<?php
$locs = [];
if (!empty($ann['show_banner'])) $locs[] = 'Top Banner';
if (!empty($ann['show_in_hours'])) $locs[] = 'Hours Card';
echo implode(', ', $locs) ?: 'None';
?>
</td>
<td data-label="Actions">
<div class="admin-table-actions">
<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;">
<input type="hidden" name="action" value="toggle_active">
<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-outline btn-sm">
<?= $ann['is_active'] ? 'Turn Off' : 'Turn On' ?>
</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>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<!-- Announcements List View — always rendered; the editor below opens
over it as a modal instead of replacing it, matching menu.php. -->
<div class="panel">
<div class="panel-head">
<h2>
<span>📢 All Announcements (<?= count($announcements) ?>)</span>
</h2>
</div>
<?php endif; ?>
<?php if (empty($announcements)): ?>
<div class="empty-state">
<p>No announcements created yet.</p>
<a href="index.php?new=1" class="btn btn-primary btn-sm">+ Create Your First Announcement</a>
</div>
<?php else: ?>
<div class="ann-grid-wrap">
<div class="ann-grid-head" aria-hidden="true">
<span class="col-status">Status</span>
<span class="col-headline">Headline</span>
<span class="col-type">Type</span>
<span class="col-schedule">Schedule</span>
<span class="col-placements">Placements</span>
<span class="col-actions">Actions</span>
</div>
<div class="ann-rows">
<?php foreach ($announcements as $ann): ?>
<?php $isCurrentlyActive = announcement_is_live($ann, $today); ?>
<div class="ann-row">
<div data-label="Status">
<form method="POST" action="index.php" class="status-toggle-form">
<input type="hidden" name="action" value="toggle_active">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
<input type="hidden" name="announcement_id" value="<?= (int)$ann['id'] ?>">
<?php if ($isCurrentlyActive): ?>
<button type="submit" class="status-badge active" title="Click to turn off">● Live</button>
<?php elseif ($ann['is_active']): ?>
<button type="submit" class="status-badge inactive" title="Click to turn off">⏳ Scheduled</button>
<?php else: ?>
<button type="submit" class="status-badge inactive" title="Click to turn on">○ Off</button>
<?php endif; ?>
</form>
</div>
<div data-label="Headline">
<strong><?= htmlspecialchars($ann['headline']) ?></strong>
<div class="muted" style="font-size: 0.82rem; margin-top: 0.2rem; max-width: 32ch; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
<?= htmlspecialchars($ann['message']) ?>
</div>
</div>
<div data-label="Type">
<span class="eyebrow"><?= htmlspecialchars($ann['type']) ?></span>
</div>
<div class="font-mono" data-label="Schedule" style="font-size: 0.8rem; color: var(--ink-muted);">
<?php if (!empty($ann['start_date']) || !empty($ann['end_date'])): ?>
<?= htmlspecialchars($ann['start_date'] ?: 'Now') ?> → <?= htmlspecialchars($ann['end_date'] ?: 'Ongoing') ?>
<?php else: ?>
Always (while on)
<?php endif; ?>
</div>
<div data-label="Placements" style="font-size: 0.82rem; color: var(--ink-muted);">
<?php
$locs = [];
if (!empty($ann['show_banner'])) $locs[] = 'Top Banner';
if (!empty($ann['show_in_hours'])) $locs[] = 'Hours Card';
echo implode(', ', $locs) ?: 'None';
?>
</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>
<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>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
</div>
</main>
<!-- Modal Backdrop: Announcement Editor (Create / Edit) — server-rendered
open/closed via the is-open class, since this page has no JS file;
the underlying save is still a plain form POST + reload, unchanged. -->
<div class="admin-modal-backdrop<?= ($isCreatingNew || $editItem) ? ' is-open' : '' ?>" role="dialog" aria-modal="true" aria-labelledby="modal-ann-title">
<div class="admin-modal">
<div class="modal-header">
<h2 id="modal-ann-title"><?= $editItem ? '✏️ Edit Announcement' : '✨ New Announcement' ?></h2>
<a href="index.php" class="modal-close" aria-label="Close dialog">&times;</a>
</div>
<form method="POST" action="index.php">
<input type="hidden" name="action" value="save_announcement">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
<input type="hidden" name="announcement_id" value="<?= (int)($editItem['id'] ?? 0) ?>">
<div class="form-grid">
<div class="form-group full-width">
<label class="checkbox-label checkbox-label-lg">
<input type="checkbox" name="is_active" value="1" <?= (!empty($editItem['is_active']) || $isCreatingNew) ? 'checked' : '' ?>>
<strong>Enable Announcement on Site</strong>
</label>
</div>
<div class="form-group">
<label for="headline">Notice Headline</label>
<input type="text" id="headline" name="headline" class="form-control"
value="<?= htmlspecialchars($editItem['headline'] ?? '') ?>"
placeholder="e.g. Labor Day Closure" required>
</div>
<div class="form-group">
<label for="type">Style / Category</label>
<select id="type" name="type" class="form-control">
<option value="holiday" <?= ($editItem['type'] ?? '') === 'holiday' ? 'selected' : '' ?>>Holiday / Special Schedule (Warm Brass & Green)</option>
<option value="special" <?= ($editItem['type'] ?? '') === 'special' ? 'selected' : '' ?>>Event / Live Music Announcement</option>
<option value="closure" <?= ($editItem['type'] ?? '') === 'closure' ? 'selected' : '' ?>>Urgent Closure / Weather Alert</option>
</select>
</div>
<div class="form-group full-width">
<label for="message">Notice Message</label>
<textarea id="message" name="message" class="form-control" required
placeholder="Write details of closure, dates, and when regular hours resume..."><?= htmlspecialchars($editItem['message'] ?? '') ?></textarea>
</div>
<div class="form-group">
<label for="start_date">Start Date (Optional Auto-Schedule)</label>
<input type="date" id="start_date" name="start_date" class="form-control"
value="<?= htmlspecialchars($editItem['start_date'] ?? '') ?>">
<span class="muted font-mono" style="font-size: 0.78rem;">Leave blank to show immediately while enabled.</span>
</div>
<div class="form-group">
<label for="end_date">End Date (Optional Expiration)</label>
<input type="date" id="end_date" name="end_date" class="form-control"
value="<?= htmlspecialchars($editItem['end_date'] ?? '') ?>">
<span class="muted font-mono" style="font-size: 0.78rem;">Auto-hides after 11:59 PM on this date.</span>
</div>
<div class="form-group full-width">
<label>Display Placements</label>
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" name="show_banner" value="1" <?= (!isset($editItem) || !empty($editItem['show_banner'])) ? 'checked' : '' ?>>
Show as top alert banner across the site
</label>
<label class="checkbox-label">
<input type="checkbox" name="show_in_hours" value="1" <?= (!isset($editItem) || !empty($editItem['show_in_hours'])) ? 'checked' : '' ?>>
Highlight inside the "Hours &amp; Visit" card
</label>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save Announcement</button>
<a href="index.php" class="btn btn-outline">Cancel</a>
</div>
</form>
</div>
</div>
<script src="index.js" defer></script>
</body>
</html>
+2 -2
View File
@@ -352,7 +352,7 @@ function itemRowHtml(item, isAvail) {
<div data-label="Status"><button type="button" class="status-badge btn-toggle-item ${isAvail ? 'active' : 'inactive'}" data-id="${item.id}" title="Click to mark as ${isAvail ? 'sold out' : 'available'}">${isAvail ? '● Available' : '○ Sold Out'}</button></div>
<div data-label="Item"><span class="item-name-row"><strong class="item-name-cell">${escapeHtml(item.name)}</strong>${badgeHtml}</span><div class="muted item-desc-cell">${escapeHtml(item.description || '')}</div></div>
<div class="font-mono item-price-cell" data-label="Price">${escapeHtml(item.price)}</div>
<div data-label="Actions"><div class="admin-table-actions">
<div data-label="Actions"><div class="row-actions">
<div class="action-stack">
<button type="button" class="btn btn-outline btn-sm btn-icon btn-move-item" data-direction="up" aria-label="Move ${escapeHtml(item.name)} up">↑</button>
<button type="button" class="btn btn-outline btn-sm btn-icon btn-move-item" data-direction="down" aria-label="Move ${escapeHtml(item.name)} down">↓</button>
@@ -432,7 +432,7 @@ function categoryPanelHtml(category) {
<span>${escapeHtml(category.name)}</span>
</h2>
<p class="muted cat-desc-text">${escapeHtml(category.description || '')}</p>
<div class="admin-table-actions">
<div class="row-actions">
<div class="action-stack">
<button type="button" class="btn btn-outline btn-sm btn-icon btn-move-cat" data-direction="up" data-id="${category.id}" aria-label="Move category up">↑</button>
<button type="button" class="btn btn-outline btn-sm btn-icon btn-move-cat" data-direction="down" data-id="${category.id}" aria-label="Move category down">↓</button>
+3 -2
View File
@@ -193,6 +193,7 @@ $menuGrouped = get_full_menu(false);
<a href="index.php">Announcements</a>
<a href="menu.php" class="active">Menu</a>
<a href="users.php">Users</a>
<a href="hours.php">Hours</a>
</div>
<div class="admin-user">
<span>Logged in as <strong><?= htmlspecialchars($user['username']) ?></strong></span>
@@ -231,7 +232,7 @@ $menuGrouped = get_full_menu(false);
<span><?= htmlspecialchars($category['name']) ?></span>
</h2>
<p class="muted cat-desc-text"><?= htmlspecialchars($category['description'] ?? '') ?></p>
<div class="admin-table-actions">
<div class="row-actions">
<div class="action-stack">
<button type="button" class="btn btn-outline btn-sm btn-icon btn-move-cat" data-direction="up" data-id="<?= (int) $category['id'] ?>" aria-label="Move <?= htmlspecialchars($category['name']) ?> category up" <?= $catIndex === 0 ? 'disabled' : '' ?>>↑</button>
<button type="button" class="btn btn-outline btn-sm btn-icon btn-move-cat" data-direction="down" data-id="<?= (int) $category['id'] ?>" aria-label="Move <?= htmlspecialchars($category['name']) ?> category down" <?= $catIndex === $catCount - 1 ? 'disabled' : '' ?>>↓</button>
@@ -284,7 +285,7 @@ $menuGrouped = get_full_menu(false);
<?= htmlspecialchars($item['price']) ?>
</div>
<div data-label="Actions">
<div class="admin-table-actions">
<div class="row-actions">
<div class="action-stack">
<button type="button" class="btn btn-outline btn-sm btn-icon btn-move-item" data-direction="up" aria-label="Move <?= htmlspecialchars($item['name']) ?> up" <?= $itemIndex === 0 ? 'disabled' : '' ?>>↑</button>
<button type="button" class="btn btn-outline btn-sm btn-icon btn-move-item" data-direction="down" aria-label="Move <?= htmlspecialchars($item['name']) ?> down" <?= $itemIndex === $itemCount - 1 ? 'disabled' : '' ?>>↓</button>
+16 -15
View File
@@ -152,8 +152,8 @@ document.getElementById('form-password').addEventListener('submit', async (e) =>
}
});
// Delegate table actions: Change Password, Delete
document.getElementById('users-tbody').addEventListener('click', async (e) => {
// Delegate row actions: Change Password, Delete
document.getElementById('users-rows').addEventListener('click', async (e) => {
const { target } = e;
if (target.classList.contains('btn-change-password')) {
@@ -163,7 +163,7 @@ document.getElementById('users-tbody').addEventListener('click', async (e) => {
if (target.classList.contains('btn-delete-user')) {
if (!confirm('Are you sure you want to delete this user? This cannot be undone.')) return;
const row = target.closest('tr');
const row = target.closest('.user-row');
const fd = new FormData();
fd.append('action', 'delete_user');
@@ -187,25 +187,26 @@ document.getElementById('users-tbody').addEventListener('click', async (e) => {
});
function insertUserRow(user) {
const tbody = document.getElementById('users-tbody');
const rowsContainer = document.getElementById('users-rows');
const tr = document.createElement('tr');
tr.id = `user-row-${user.id}`;
tr.dataset.userId = user.id;
const row = document.createElement('div');
row.className = 'user-row';
row.id = `user-row-${user.id}`;
row.dataset.userId = user.id;
// 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.
tr.innerHTML = `
<td data-label="Username"><strong>${escapeHtml(user.username)}</strong></td>
<td data-label="Role">${escapeHtml(user.role.charAt(0).toUpperCase() + user.role.slice(1))}</td>
<td class="font-mono item-sort-cell" data-label="Created">${escapeHtml(user.created_at)}</td>
<td data-label="Actions"><div class="admin-table-actions">
row.innerHTML = `
<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">
<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></td>
</div></div>
`;
tr.querySelector('.btn-change-password').dataset.username = user.username;
row.querySelector('.btn-change-password').dataset.username = user.username;
tbody.appendChild(tr);
rowsContainer.appendChild(row);
}
+41 -38
View File
@@ -161,6 +161,7 @@ $users = get_all_users();
<a href="index.php">Announcements</a>
<a href="menu.php">Menu</a>
<a href="users.php" class="active">Users</a>
<a href="hours.php">Hours</a>
</div>
<div class="admin-user">
<span>Logged in as <strong><?= htmlspecialchars($user['username']) ?></strong></span>
@@ -182,45 +183,47 @@ $users = get_all_users();
</div>
<div class="panel">
<div class="admin-table-wrap">
<table class="admin-table" id="users-table">
<thead>
<tr>
<th>Username</th>
<th>Role</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="users-tbody">
<?php foreach ($users as $row): ?>
<tr id="user-row-<?= (int) $row['id'] ?>" data-user-id="<?= (int) $row['id'] ?>">
<td data-label="Username">
<strong><?= htmlspecialchars($row['username']) ?></strong>
<?php if ((int) $row['id'] === (int) $user['id']): ?>
<span class="muted"> (you)</span>
<?php endif; ?>
</td>
<td data-label="Role"><?= htmlspecialchars(ucfirst($row['role'])) ?></td>
<td class="font-mono item-sort-cell" data-label="Created"><?= htmlspecialchars($row['created_at']) ?></td>
<td data-label="Actions">
<div class="admin-table-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
<div class="panel-head">
<h2>
<span>👤 All Users (<?= count($users) ?>)</span>
</h2>
</div>
<div class="user-grid-wrap">
<div class="user-grid-head" aria-hidden="true">
<span class="col-username">Username</span>
<span class="col-role">Role</span>
<span class="col-created">Created</span>
<span class="col-actions">Actions</span>
</div>
<div class="user-rows" id="users-rows">
<?php foreach ($users as $row): ?>
<div class="user-row" id="user-row-<?= (int) $row['id'] ?>" data-user-id="<?= (int) $row['id'] ?>">
<div data-label="Username">
<strong><?= htmlspecialchars($row['username']) ?></strong>
<?php if ((int) $row['id'] === (int) $user['id']): ?>
<span class="muted"> (you)</span>
<?php endif; ?>
</div>
<div data-label="Role"><?= htmlspecialchars(ucfirst($row['role'])) ?></div>
<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
</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
</button>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
+38
View File
@@ -77,6 +77,15 @@ function init_db_schema(PDO $pdo): void {
sort_order INTEGER NOT NULL DEFAULT 0,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS pub_hours (
day_of_week INTEGER PRIMARY KEY,
is_open INTEGER NOT NULL DEFAULT 0,
open_time TEXT NULL,
close_time TEXT NULL,
kitchen_close_time TEXT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
");
// Seed default admin user if table is empty (username: admin / password: password)
@@ -87,4 +96,33 @@ function init_db_schema(PDO $pdo): void {
$insert = $pdo->prepare("INSERT INTO users (username, password_hash, role) VALUES (?, ?, 'admin')");
$insert->execute(['admin', $defaultPassHash]);
}
// Seed default weekly hours if the table is empty: closed Sun–Wed,
// 4–10pm Thu–Sat with the kitchen closing at 9 — matches the hours the
// site originally shipped with as hardcoded copy.
$stmt = $pdo->query("SELECT COUNT(*) as count FROM pub_hours");
$hoursCount = $stmt->fetchColumn();
if ($hoursCount == 0) {
$insert = $pdo->prepare("
INSERT INTO pub_hours (day_of_week, is_open, open_time, close_time, kitchen_close_time)
VALUES (?, ?, ?, ?, ?)
");
// 0=Sunday ... 6=Saturday
$defaults = [
0 => null, // Sunday — closed
1 => null, // Monday — closed
2 => null, // Tuesday — closed
3 => null, // Wednesday — closed
4 => ['16:00', '22:00', '21:00'], // Thursday
5 => ['16:00', '22:00', '21:00'], // Friday
6 => ['16:00', '22:00', '21:00'], // Saturday
];
foreach ($defaults as $day => $times) {
if ($times === null) {
$insert->execute([$day, 0, null, null, null]);
} else {
$insert->execute([$day, 1, $times[0], $times[1], $times[2]]);
}
}
}
}
+294
View File
@@ -0,0 +1,294 @@
<?php
declare(strict_types=1);
/**
* Pub hours helper functions.
*
* Days are keyed 0=Sunday .. 6=Saturday throughout (SQLite's/JS's own
* convention), so callers can index straight into HOURS_DAY_NAMES etc.
* without any Monday-first translation.
*/
require_once __DIR__ . '/db.php';
const HOURS_DAY_NAMES = [
0 => 'Sunday', 1 => 'Monday', 2 => 'Tuesday', 3 => 'Wednesday',
4 => 'Thursday', 5 => 'Friday', 6 => 'Saturday',
];
const HOURS_DAY_ABBR = [
0 => 'Sun', 1 => 'Mon', 2 => 'Tue', 3 => 'Wed',
4 => 'Thu', 5 => 'Fri', 6 => 'Sat',
];
/**
* Returns all 7 days, 0..6, always fully populated (a day missing from the
* table — shouldn't happen once seeded, but this keeps every caller below
* from having to guard against a gap) defaults to closed.
*/
function get_all_hours(): array {
$pdo = get_db();
$rows = $pdo->query("SELECT * FROM pub_hours ORDER BY day_of_week ASC")->fetchAll() ?: [];
$byDay = [];
foreach ($rows as $row) {
$byDay[(int) $row['day_of_week']] = $row;
}
$result = [];
for ($d = 0; $d <= 6; $d++) {
$result[$d] = $byDay[$d] ?? [
'day_of_week' => $d,
'is_open' => 0,
'open_time' => null,
'close_time' => null,
'kitchen_close_time' => null,
];
}
return $result;
}
/**
* Normalizes a submitted time value down to "HH:MM", or null if it's blank
* or not something an <input type="time"> would ever actually send.
*/
function normalize_time_value(?string $value): ?string {
if ($value === null) {
return null;
}
$value = trim($value);
if ($value === '') {
return null;
}
if (!preg_match('/^([01]?\d|2[0-3]):([0-5]\d)/', $value, $m)) {
return null;
}
return sprintf('%02d:%s', (int) $m[1], $m[2]);
}
/**
* Persists all 7 days in one go from the admin form's day[N][...] fields.
* A day marked closed has its times forced to null regardless of whatever
* stale values its (disabled) time inputs may have carried in the POST.
*/
function save_all_hours(array $daysInput): void {
$pdo = get_db();
$stmt = $pdo->prepare("
INSERT INTO pub_hours (day_of_week, is_open, open_time, close_time, kitchen_close_time, updated_at)
VALUES (:day, :is_open, :open_time, :close_time, :kitchen_close_time, CURRENT_TIMESTAMP)
ON CONFLICT(day_of_week) DO UPDATE SET
is_open = excluded.is_open,
open_time = excluded.open_time,
close_time = excluded.close_time,
kitchen_close_time = excluded.kitchen_close_time,
updated_at = CURRENT_TIMESTAMP
");
for ($d = 0; $d <= 6; $d++) {
$dayData = is_array($daysInput[$d] ?? null) ? $daysInput[$d] : [];
$isOpen = !empty($dayData['is_open']) ? 1 : 0;
$openTime = normalize_time_value($dayData['open_time'] ?? null);
$closeTime = normalize_time_value($dayData['close_time'] ?? null);
$kitchenClose = normalize_time_value($dayData['kitchen_close_time'] ?? null);
if (!$isOpen) {
$openTime = null;
$closeTime = null;
$kitchenClose = null;
}
$stmt->execute([
':day' => $d,
':is_open' => $isOpen,
':open_time' => $openTime,
':close_time' => $closeTime,
':kitchen_close_time' => $kitchenClose,
]);
}
}
/**
* "4:00 PM" / "9 AM" — 12-hour, minutes dropped when they're :00 and
* $dropZeroMinutes is set (used for one-off mentions like the kitchen note;
* ranges below always keep :00 so both ends line up visually).
*/
function format_time_12h(?string $hhmm, bool $dropZeroMinutes = false): string {
if (!$hhmm) {
return '';
}
[$h, $m] = array_map('intval', explode(':', $hhmm));
$period = $h >= 12 ? 'PM' : 'AM';
$hour12 = $h % 12;
if ($hour12 === 0) {
$hour12 = 12;
}
if ($dropZeroMinutes && $m === 0) {
return "{$hour12} {$period}";
}
return sprintf('%d:%02d %s', $hour12, $m, $period);
}
/**
* "4:00 – 10:00 PM" (or "11:00 AM – 2:00 PM" when the two ends straddle
* noon) — a single trailing meridiem when both ends share one, otherwise
* both spelled out. $dash lets callers pick the tight "–" the footer's
* condensed line uses vs the spaced " – " the hours card uses.
*/
function format_time_range(string $openTime, string $closeTime, string $dash = ' – '): string {
$openHour = (int) explode(':', $openTime)[0];
$closeHour = (int) explode(':', $closeTime)[0];
$openPeriod = $openHour >= 12 ? 'PM' : 'AM';
$closePeriod = $closeHour >= 12 ? 'PM' : 'AM';
$openLabel = format_time_12h($openTime);
$closeLabel = format_time_12h($closeTime);
if ($openPeriod === $closePeriod) {
$openLabel = trim(str_replace($openPeriod, '', $openLabel));
}
return "{$openLabel}{$dash}{$closeLabel}";
}
/**
* Groups the week (in Sun..Sat order) into consecutive runs sharing the
* same open/closed status and, if open, the same start/end time. Doesn't
* wrap around from Saturday back to Sunday — a closed Sat + Sun would come
* back as two separate single-day groups rather than one "Sat – Sun" — but
* that's an edge case this pub's schedule doesn't hit, and not worth the
* extra complexity of circular grouping for.
*/
function group_hours_into_ranges(array $hoursByDay): array {
$groups = [];
$current = null;
for ($d = 0; $d <= 6; $d++) {
$day = $hoursByDay[$d];
$isOpen = !empty($day['is_open']) && $day['open_time'] && $day['close_time'];
$key = $isOpen ? 'open|' . $day['open_time'] . '|' . $day['close_time'] : 'closed';
if ($current !== null && $current['key'] === $key) {
$current['days'][] = $d;
} else {
if ($current !== null) {
$groups[] = $current;
}
$current = ['key' => $key, 'is_open' => $isOpen, 'days' => [$d], 'data' => $day];
}
}
if ($current !== null) {
$groups[] = $current;
}
return $groups;
}
/**
* Rows for the "Hours & Visit" card: every open day gets its own line (even
* if it's identical to its neighbor's), any run of closed days collapses
* into one summary row — matches the level of detail the site originally
* hardcoded (Thursday / Friday / Saturday individually, "Sun – Wed" combined).
*/
function hours_card_rows(array $hoursByDay): array {
// Open days are collected separately from closed runs, and always
// rendered first — matching the original hardcoded card, which led
// with the days you *can* visit and tucked the closed stretch at the
// bottom as a footnote, rather than the plain chronological (Sun..Sat)
// order that would otherwise put a Sun–Wed closed run up top.
$openRows = [];
$closedGroups = [];
$closedRun = [];
for ($d = 0; $d <= 6; $d++) {
$day = $hoursByDay[$d];
$isOpen = !empty($day['is_open']) && $day['open_time'] && $day['close_time'];
if ($isOpen) {
if (!empty($closedRun)) {
$closedGroups[] = $closedRun;
$closedRun = [];
}
$openRows[] = ['label' => HOURS_DAY_NAMES[$d], 'text' => format_time_range($day['open_time'], $day['close_time'])];
} else {
$closedRun[] = $d;
}
}
if (!empty($closedRun)) {
$closedGroups[] = $closedRun;
}
$rows = $openRows;
foreach ($closedGroups as $group) {
$rows[] = ['label' => day_abbr_range($group), 'text' => 'Closed'];
}
return $rows;
}
/**
* Condensed lines for the footer's compact hours summary: every group
* (open or closed) collapses to one line, open groups listed first — the
* order the site's footer originally used — with the kitchen note (if any)
* inserted between the open and closed lines.
*/
function hours_footer_lines(array $hoursByDay): array {
$groups = group_hours_into_ranges($hoursByDay);
$openLines = [];
$closedLines = [];
foreach ($groups as $group) {
$label = day_abbr_range($group['days'], ' — ');
if ($group['is_open']) {
$openLines[] = $label . ' · ' . format_time_range($group['data']['open_time'], $group['data']['close_time'], '–');
} else {
$closedLines[] = $label . ' · Closed';
}
}
$lines = $openLines;
$kitchenNote = hours_kitchen_note($hoursByDay);
if ($kitchenNote) {
$lines[] = $kitchenNote;
}
return array_merge($lines, $closedLines);
}
/**
* "Sun" for a single day, "Sun – Wed" (or with a custom $dash) for a run.
*/
function day_abbr_range(array $days, string $dash = ' – '): string {
if (count($days) === 1) {
return HOURS_DAY_ABBR[$days[0]];
}
return HOURS_DAY_ABBR[$days[0]] . $dash . HOURS_DAY_ABBR[end($days)];
}
/**
* "Kitchen closes at 9 PM" — or, if open days don't all share one kitchen
* closing time, one clause per distinct time naming which days it applies
* to. Returns null when no open day has a kitchen closing time set, so
* callers can omit the note entirely rather than print an empty sentence.
*/
function hours_kitchen_note(array $hoursByDay, bool $dropZeroMinutes = true): ?string {
$byTime = [];
for ($d = 0; $d <= 6; $d++) {
$day = $hoursByDay[$d];
if (!empty($day['is_open']) && !empty($day['kitchen_close_time'])) {
$byTime[$day['kitchen_close_time']][] = $d;
}
}
if (empty($byTime)) {
return null;
}
if (count($byTime) === 1) {
$time = array_key_first($byTime);
return 'Kitchen closes at ' . format_time_12h($time, $dropZeroMinutes);
}
$parts = [];
foreach ($byTime as $time => $days) {
$parts[] = format_time_12h($time, $dropZeroMinutes) . ' (' . day_abbr_range($days, '–') . ')';
}
return 'Kitchen closes at ' . implode(', ', $parts);
}
+13 -20
View File
@@ -2,8 +2,10 @@
require __DIR__ . '/config.php';
require __DIR__ . '/inc/facebook-stats.php';
require __DIR__ . '/inc/announcements.php';
require __DIR__ . '/inc/hours.php';
$fbStats = fritz_facebook_stats();
$activeAnnouncements = get_active_announcements();
$hoursByDay = get_all_hours();
$siteUrl = rtrim(SITE_URL, '/');
?>
<!doctype html>
@@ -157,24 +159,15 @@ $siteUrl = rtrim(SITE_URL, '/');
</div>
<?php endforeach; ?>
<dl class="hours-list">
<div class="hours-row">
<dt>Thursday</dt>
<dd>4:00 – 10:00 PM</dd>
</div>
<div class="hours-row">
<dt>Friday</dt>
<dd>4:00 – 10:00 PM</dd>
</div>
<div class="hours-row">
<dt>Saturday</dt>
<dd>4:00 – 10:00 PM</dd>
</div>
<div class="hours-row">
<dt>Sun – Wed</dt>
<dd>Closed</dd>
</div>
<?php foreach (hours_card_rows($hoursByDay) as $row): ?>
<div class="hours-row">
<dt><?= htmlspecialchars($row['label']) ?></dt>
<dd><?= htmlspecialchars($row['text']) ?></dd>
</div>
<?php endforeach; ?>
</dl>
<p class="note"><strong>Kitchen closes at 9</strong> — bar snacks stay out later. Holiday hours may vary; check our Facebook page for the latest.</p>
<?php $kitchenNote = hours_kitchen_note($hoursByDay); ?>
<p class="note"><?php if ($kitchenNote): ?><strong><?= htmlspecialchars($kitchenNote) ?></strong> — bar snacks stay out later. <?php endif; ?>Holiday hours may vary; check our Facebook page for the latest.</p>
</div>
<div class="card find-us reveal" id="contact">
<svg class="pin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
@@ -266,9 +259,9 @@ $siteUrl = rtrim(SITE_URL, '/');
<div>
<p class="col-title">Hours</p>
<div class="hours-mini">
<span>Thu — Sat · 4:00–10:00 PM</span>
<span>Kitchen closes at 9 PM</span>
<span>Sun — Wed · Closed</span>
<?php foreach (hours_footer_lines($hoursByDay) as $line): ?>
<span><?= htmlspecialchars($line) ?></span>
<?php endforeach; ?>
</div>
</div>
<div>
+5 -3
View File
@@ -4,9 +4,11 @@ declare(strict_types=1);
require __DIR__ . '/config.php';
require __DIR__ . '/inc/announcements.php';
require __DIR__ . '/inc/menu.php';
require __DIR__ . '/inc/hours.php';
$activeAnnouncements = get_active_announcements();
$menuCategories = get_full_menu(true); // available items only
$hoursByDay = get_all_hours();
$siteUrl = rtrim(SITE_URL, '/');
?>
<!doctype html>
@@ -177,9 +179,9 @@ $siteUrl = rtrim(SITE_URL, '/');
<div>
<p class="col-title">Hours</p>
<div class="hours-mini">
<span>Thu — Sat · 4:00–10:00 PM</span>
<span>Kitchen closes at 9 PM</span>
<span>Sun — Wed · Closed</span>
<?php foreach (hours_footer_lines($hoursByDay) as $line): ?>
<span><?= htmlspecialchars($line) ?></span>
<?php endforeach; ?>
</div>
</div>
<div>
+335 -175
View File
@@ -87,18 +87,6 @@
color: var(--ink-muted);
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
padding: 0;
margin: -1px;
}
.skip-link {
position: absolute;
left: 1rem;
@@ -170,10 +158,6 @@
background: linear-gradient(90deg, transparent, var(--gold-dim), transparent);
}
.rule-row.left-align {
margin-inline: 0;
}
/* ---------- announcement banner ---------- */
.announcement-banner {
background: var(--ground-2);
@@ -1123,15 +1107,6 @@
margin-bottom: 1.25rem;
}
.alert-success {
background: rgba(51, 80, 63, 0.25);
border: 1px solid var(--green);
color: #bbf7d0;
padding: 0.9rem 1.25rem;
border-radius: 0.125rem;
margin-bottom: 1.5rem;
}
.login-btn {
width: 100%;
justify-content: center;
@@ -1320,20 +1295,19 @@
color: var(--ink-muted);
}
/* The menu admin's status badge doubles as its own availability toggle
(see .btn-toggle-item) — a plain <button> would otherwise pick up the
/* Both the menu item and announcement status badges double as their own
toggle control — a plain <button> would otherwise pick up the
browser's default chrome underneath the badge's own background/border.
Scoped to the element type so the read-only status badges elsewhere
(e.g. the announcements table) aren't affected. */
/* Fixed to fit its longest possible label ("● Available", ~6.7rem
measured) so toggling doesn't change the badge's — and therefore the
Status column's — width and shove every other column sideways. */
Scoped to the element type so it's a no-op wherever a status-badge is
still a plain read-only <span>. Width is fixed per context below
(each page's label set is a different length) so toggling never
changes the badge's — and therefore the Status column's — width and
shoves every other column sideways. */
button.status-badge {
appearance: none;
-webkit-appearance: none;
cursor: pointer;
justify-content: center;
width: 7rem;
transition: opacity .15s ease;
}
@@ -1346,40 +1320,22 @@
opacity: 0.5;
}
/* admin table & list actions */
.admin-table-wrap {
overflow-x: auto;
margin-top: 1rem;
/* "● Available" is the longest menu item label, ~6.7rem measured. */
.item-row button.status-badge {
width: 7rem;
}
.admin-table {
width: 100%;
border-collapse: collapse;
font-size: 0.92rem;
/* "⏳ Scheduled" is the longest announcement label, ~7rem measured. */
.ann-row button.status-badge {
width: 7.25rem;
}
.admin-table th,
.admin-table td {
padding: 0.85rem 0.75rem;
text-align: left;
border-bottom: 1px solid var(--rule);
vertical-align: middle;
}
.admin-table th {
font-family: var(--font-mono);
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--gold);
font-weight: 500;
}
.admin-table tr:last-child td {
border-bottom: none;
}
.admin-table-actions {
/* Admin row actions: the button-group styling shared by every admin
list (menu items/categories, announcements, users). The actual
row/column layout for each list is its own CSS Grid, defined where
that list's markup lives below — there is no <table> anywhere in
this project. */
.row-actions {
display: inline-flex;
gap: 0.5rem;
align-items: center;
@@ -1387,86 +1343,24 @@
white-space: nowrap;
}
.admin-table-actions .btn {
.row-actions .btn {
white-space: nowrap;
}
/* Groups the item row's move (↑/↓) and edit/delete controls into two
narrow vertical stacks instead of four buttons side by side — keeps
the Actions column (and the table as a whole) from running wide. */
/* Groups a row's move (↑/↓) and edit/delete controls into two narrow
vertical stacks instead of several buttons side by side — keeps the
Actions column (and the row as a whole) from running wide. */
.action-stack {
display: inline-flex;
flex-direction: column;
gap: 0.35rem;
}
/* Below ~760px a plain <table> 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 <th> 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 {
.row-actions {
flex-wrap: wrap;
white-space: normal;
}
/* Right-align the action buttons instead of leaving them stranded at
the card's left edge — item rows (an inline-flex box in a
full-width block <td>, so text-align positions it) and category
headers (a flex item in .panel-head, so margin-left:auto pushes it
to the end of its line) need different techniques to get there. */
.admin-table td[data-label="Actions"] {
text-align: right;
}
.panel-head .admin-table-actions {
margin-left: auto;
}
}
.btn-sm {
@@ -1623,15 +1517,12 @@
font-weight: normal;
}
/* admin menu grid: category panel header + item row formatting.
Shared between admin/menu.php's server render and admin/menu.js's
client-side row/panel templates, so it lives here once instead of
being duplicated as inline styles in both places.
This — not <table>/<tr>/<td> — is the item list's actual layout.
Desktop and mobile each get their own grid-template-areas map (see the
mobile override below), so the two can be redesigned independently
instead of one being a display:block hack forced onto the other. */
/* Admin list grids: menu items/categories, announcements, and users all
share one CSS Grid pattern instead of a <table> — see the git history
for why. Every list's header row and data rows are grouped under one
selector here for the properties they all share (box model,
typography, the mobile reflow); each list then only has to declare
its own grid-template-columns/areas, i.e. the shape unique to it. */
.font-mono {
font-family: var(--font-mono);
}
@@ -1664,27 +1555,56 @@
column-gap: 0.75rem;
}
.category-panel .panel-head .admin-table-actions {
.category-panel .panel-head .row-actions {
grid-area: actions;
}
/* Each category's item list: a header row of column labels (desktop
only — hidden on mobile below) plus one grid per item row, all
sharing the same column template so they line up. */
/* Each list's real column tracks live on the -grid-wrap — the one
shared parent grid. The header row and every data row don't compute
their own auto columns (which is what let them drift out of
alignment: each was sizing "auto" only against its own content); they
subgrid instead, inheriting the exact same tracks from this parent.
.foo-rows is display:contents purely so .foo-row stays a direct grid
child of .foo-grid-wrap for that inheritance — it has no visual
footprint of its own. */
.item-grid-wrap {
grid-template-columns: auto minmax(0, 1fr) auto auto;
}
.ann-grid-wrap {
grid-template-columns: auto minmax(0, 1fr) auto auto auto auto;
}
.user-grid-wrap {
grid-template-columns: minmax(0, 1fr) auto auto auto;
}
.item-grid-wrap,
.ann-grid-wrap,
.user-grid-wrap {
display: grid;
column-gap: 0.75rem;
margin-top: 1rem;
}
.item-grid-head,
.item-row {
.item-rows,
.ann-rows,
.user-rows {
display: contents;
}
.item-grid-head, .item-row,
.ann-grid-head, .ann-row,
.user-grid-head, .user-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
grid-template-areas: "status item price actions";
column-gap: 0.75rem;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
}
.item-grid-head {
.item-grid-head,
.ann-grid-head,
.user-grid-head {
padding: 0 0.75rem 0.85rem;
border-bottom: 1px solid var(--rule);
font-family: var(--font-mono);
@@ -1695,26 +1615,73 @@
font-weight: 500;
}
.item-row {
.item-row,
.ann-row,
.user-row {
padding: 0.85rem 0.75rem;
border-bottom: 1px solid var(--rule);
}
.item-rows .item-row:last-child {
.item-row:last-child,
.ann-row:last-child,
.user-row:last-child {
border-bottom: none;
}
.col-status { grid-area: status; }
/* Each list's own shape: which named area each field maps to. "Status"
→ status and "Actions" → actions mean the same thing in all three, so
those two are declared once. */
.item-grid-head, .item-row {
grid-template-areas: "status item price actions";
}
.ann-grid-head, .ann-row {
grid-template-areas: "status headline type schedule placements actions";
}
.user-grid-head, .user-row {
grid-template-areas: "username role created actions";
}
.col-status,
.item-row [data-label="Status"],
.ann-row [data-label="Status"] {
grid-area: status;
}
.col-actions,
.item-row [data-label="Actions"],
.ann-row [data-label="Actions"],
.user-row [data-label="Actions"] {
grid-area: actions;
}
.col-item { grid-area: item; }
.col-price { grid-area: price; }
.col-actions { grid-area: actions; }
.item-row [data-label="Status"] { grid-area: status; }
.item-row [data-label="Item"] { grid-area: item; }
.item-row [data-label="Price"] { grid-area: price; }
.item-row [data-label="Actions"] { grid-area: actions; }
.col-headline { grid-area: headline; }
.col-type { grid-area: type; }
.col-schedule { grid-area: schedule; }
.col-placements { grid-area: placements; }
.ann-row [data-label="Headline"] { grid-area: headline; }
.ann-row [data-label="Type"] { grid-area: type; }
.ann-row [data-label="Schedule"] { grid-area: schedule; }
.ann-row [data-label="Placements"] { grid-area: placements; }
.col-username { grid-area: username; }
.col-role { grid-area: role; }
.col-created { grid-area: created; }
.user-row [data-label="Username"] { grid-area: username; }
.user-row [data-label="Role"] { grid-area: role; }
.user-row [data-label="Created"] { grid-area: created; }
/* .item-rows being display:contents makes this a direct child of the
.item-grid-wrap grid when a category is empty — span every column or
it lands squeezed into just the first (Status) track. */
.item-grid-empty {
grid-column: 1 / -1;
text-align: center;
padding: 1.5rem 0;
}
@@ -1739,29 +1706,41 @@
font-weight: 500;
}
.item-sort-cell {
.timestamp-cell {
color: var(--ink-muted);
font-size: 0.8rem;
}
/* Menu admin grid: its own mobile arrangement, independent of the
desktop one above — this is the whole point of grid-template-areas
over the <table> hack. Same fields, different map. Placed after the
desktop rules above (rather than in the earlier shared .admin-table
mobile block) so it wins the cascade at equal specificity — a rule's
source position still matters once a media query lets both compete. */
/* Each grid's own mobile arrangement, independent of the desktop one
above — this is the whole point of grid-template-areas over a
<table>. Same fields, different map, per list. Placed after the
desktop rules above so it wins the cascade at equal specificity — a
rule's source position still matters once a media query lets both
compete. The reflow mechanics (hide the header row, stack fields with
a printed label, pin Actions to the end) are identical across all
three lists and the category header, so those are shared too; only
each one's grid-template-areas differs. */
@media (max-width: 47.5em) {
.item-grid-head {
/* The wrap collapses to a single column, and every subgridded row
below inherits that automatically — they keep `grid-template-
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
further down for how each field then stacks within that column). */
.item-grid-wrap,
.ann-grid-wrap,
.user-grid-wrap {
grid-template-columns: 1fr;
}
.item-grid-head,
.ann-grid-head,
.user-grid-head {
display: none;
}
.item-row {
grid-template-columns: 1fr;
grid-template-areas:
"status"
"item"
"price"
"actions";
.item-row,
.ann-row,
.user-row {
row-gap: 0.4rem;
border: 1px solid var(--rule);
border-radius: 0.25rem;
@@ -1769,12 +1748,16 @@
margin-bottom: 0.85rem;
}
.item-rows .item-row:last-child {
.item-row:last-child,
.ann-row:last-child,
.user-row:last-child {
border-bottom: none;
margin-bottom: 0;
}
.item-row [data-label]::before {
.item-row [data-label]::before,
.ann-row [data-label]::before,
.user-row [data-label]::before {
content: attr(data-label);
display: block;
font-family: var(--font-mono);
@@ -1785,8 +1768,30 @@
margin-bottom: 0.3rem;
}
.item-row [data-label="Actions"] {
justify-self: end;
.item-row {
grid-template-areas:
"status"
"item"
"price"
"actions";
}
.ann-row {
grid-template-areas:
"status"
"headline"
"type"
"schedule"
"placements"
"actions";
}
.user-row {
grid-template-areas:
"username"
"role"
"created"
"actions";
}
.category-panel .panel-head {
@@ -1861,6 +1866,7 @@
cursor: pointer;
padding: 0.25rem;
line-height: 1;
text-decoration: none;
transition: color .15s ease;
}
@@ -1921,4 +1927,158 @@
.menu-page-content {
padding-bottom: 5.5rem;
}
.alert-success {
background: rgba(51, 128, 90, 0.15);
border: 1px solid var(--green);
color: #9fe2bf;
padding: 0.85rem 1.1rem;
font-size: 0.88rem;
border-radius: 0.125rem;
margin-bottom: 1.25rem;
}
/* ---------- admin: pub hours ---------- */
/* Same subgrid technique as the item/ann/user lists above (see the
comment near .item-grid-wrap), but with no grid-template-areas: every
row's five fields sit in the same left-to-right order on both desktop
and mobile, so plain column order is enough — nothing needs to reflow. */
.hours-grid-wrap {
display: grid;
grid-template-columns: auto auto 1fr 1fr 1fr;
column-gap: 0.75rem;
margin-top: 1rem;
}
.hours-day-rows {
display: contents;
}
.hours-grid-head,
.hours-day-row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
}
.hours-grid-head {
padding: 0 0.75rem 0.85rem;
border-bottom: 1px solid var(--rule);
font-family: var(--font-mono);
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--gold);
font-weight: 500;
}
.hours-day-row {
padding: 0.85rem 0.75rem;
border-bottom: 1px solid var(--rule);
}
.hours-day-row:last-child {
border-bottom: none;
}
.hours-day-row .form-control {
min-width: 7rem;
}
.hours-day-row .day-time-field:disabled {
opacity: 0.4;
}
@media (max-width: 47.5em) {
.hours-grid-wrap {
grid-template-columns: 1fr;
}
.hours-grid-head {
display: none;
}
.hours-day-row {
grid-template-columns: 1fr;
row-gap: 0.5rem;
border: 1px solid var(--rule);
border-radius: 0.25rem;
padding: 0.9rem 1rem;
margin-bottom: 0.85rem;
}
.hours-day-row:last-child {
margin-bottom: 0;
}
.hours-day-row [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;
}
.hours-day-row .form-control {
width: 100%;
}
}
/* Live preview panel: mimics the public site's Visit card and footer
mini-summary without depending on their .card/footer ancestor scoping
(this lives inside an admin .panel, which already shares that same
surface look — nesting the real classes here would just blend in). */
.hours-preview {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.hours-preview-card,
.hours-preview-footer {
background: var(--ground-2);
border: 1px solid var(--rule);
border-radius: 0.2rem;
padding: 1.5rem;
}
.hours-preview-card .note {
margin-top: 1.25rem;
font-size: 0.88rem;
color: var(--ink-muted);
}
.hours-preview-card .note strong {
color: var(--ink);
font-weight: 600;
}
.hours-preview-footer .col-title {
font-family: var(--font-mono);
font-size: 0.72rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--gold-dim);
margin-bottom: 0.9rem;
}
.hours-preview-footer .hours-mini {
font-family: var(--font-mono);
font-size: 0.85rem;
color: var(--ink-muted);
font-variant-numeric: tabular-nums;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
@media (max-width: 47.5em) {
.hours-preview {
grid-template-columns: 1fr;
}
}