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:
1 parent
fd928a24cf
commit
eab20899de
14 files changed
+1139
-356
No files matched your search
@@ -15,3 +15,4 @@ db/fitz.sqlite-shm
|
||||
|
||||
.hintrc
|
||||
dev.sh
|
||||
.claude/
|
||||
@@ -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
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
+152
-119
@@ -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,28 +139,115 @@ $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) -->
|
||||
<div class="admin-toolbar">
|
||||
<a href="index.php?new=1" class="btn btn-primary btn-sm">+ Create Announcement</a>
|
||||
</div>
|
||||
|
||||
<!-- 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 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">×</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="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">
|
||||
@@ -181,108 +311,11 @@ $today = date('Y-m-d');
|
||||
<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; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="index.js" defer></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+2
-2
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
+25
-22
@@ -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,29 +183,32 @@ $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">
|
||||
<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): ?>
|
||||
<tr id="user-row-<?= (int) $row['id'] ?>" data-user-id="<?= (int) $row['id'] ?>">
|
||||
<td data-label="Username">
|
||||
<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; ?>
|
||||
</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">
|
||||
</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']) ?>">
|
||||
@@ -216,11 +220,10 @@ $users = get_all_users();
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+38
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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">
|
||||
<?php foreach (hours_card_rows($hoursByDay) as $row): ?>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1922,3 +1928,157 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user