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

+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;
}
}