Compare commits

..
28 Commits
Author SHA1 Message Date
David Dyer 242c5165e1 Refactor admin panel: enhance user management and security features
- Added role-based access control for user management links in admin navigation.
- Improved user account display with a dedicated account link.
- Implemented form submission confirmation for delete actions in the admin interface.
- Introduced a password visibility toggle on the login page.
- Added login throttling to prevent brute-force attacks, recording failed attempts.
- Created a CLI script for creating an admin user, replacing the previous hardcoded admin credentials.
- Updated database schema to support login attempt tracking.
- Enhanced user editing functionality, allowing role changes with restrictions.
- Improved modal handling for user actions, ensuring better focus management.
- Added styles for new account management page and improved modal transitions.
2026-10-25 12:58:19 -04:00
David DyerandClaude Sonnet 5 91ec95a4a9 Upgrade PHP image to 8.5-apache
PHP 8.3.33 (running in prod) is vulnerable to the PHP2Shell RCE flaws
(CVE-2026-109618, CVE-2026-109619) fixed upstream in 8.3.36/8.4.27/8.5.12.
8.5 also has the longest remaining support window and matches the local
dev PHP version already in use.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-22 17:35:44 -04:00
David DyerandClaude Sonnet 5 e0904b368c Derive meta-description hours from Hours & Visit data instead of hardcoding
The <meta description>, og:description, and twitter:description tags all
had "Open Thursday through Saturday, 4–10pm" typed in by hand, so editing
hours in the admin panel silently left the page's search/social preview
copy wrong.

Added hours_meta_summary() to inc/hours.php, built from the same
group_hours_into_ranges() grouping the Hours & Visit card and footer
already use, so all three now read the live pub_hours data and stay in
sync with whatever's set under Hours & Visit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 10:27:42 -04:00
David DyerandClaude Sonnet 5 bbe84e8614 Add filemtime-based cache busting for styles.css and main.js
Safari caches static assets far more aggressively than Chrome/Firefox
and largely ignores heuristic revalidation, so a new deploy could keep
showing the old CSS/JS indefinitely even on a hard refresh — no
Cache-Control headers were set anywhere to guide it otherwise.

inc/assets.php adds asset_version($path), which appends a ?v=<mtime>
query string derived from the file's last-modified time. Every page
that links styles.css or main.js (index.php, menu.php, and all five
admin pages) now uses it, so the URL changes whenever the file's
contents change and browsers always fetch the new version.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 10:23:40 -04:00
David DyerandClaude Sonnet 5 375407245c Fix dist script: point it at build:sass, not the removed build script
npm run dist called "npm run build", but that script had been renamed
to "build:sass" without updating the reference, so dist errored out
before ever running scripts/build-dist.js.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 22:07:42 -04:00
David DyerandClaude Sonnet 5 11c72d1231 Point Say Hello at Facebook, drop redundant Contact nav item
- Hero "Say Hello" button now opens the Facebook page instead of a
  mailto: link, matching the other social CTAs on the page.
- Removed the "Contact" nav link since "Hours & Visit" already scrolls
  to the same section (which includes the Find Us / contact card).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 22:00:43 -04:00
David DyerandClaude Sonnet 5 3e5d804bc3 Add top margin to homepage stat card; minor whitespace cleanup
- components/_cards.scss: .stat-card gets margin-top: 2rem; rebuilt
  styles.css to match.
- index.php: strip trailing whitespace on a blank line in the music
  section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 19:33:37 -04:00
David DyerandClaude Sonnet 5 9a32d34662 Split styles.scss into a standard 7-1-style Sass structure
Breaks the single 2,167-line styles.scss into abstracts/base/components/
layout/pages partials, forwarded from styles.scss (now a manifest only)
via @use. Organized by what each rule is/does rather than by page, since
a lot of it is intentionally shared — the item/announcement/user admin
list grids share one CSS Grid system across three admin pages, and
.knot/.rule-row/.hero-ctas/the announcement banner are used on both
index.php and menu.php. Splitting by page would have fought that.

  abstracts/tokens        — :root custom properties
  base/reset, utilities   — bare-element defaults, small reusable utility classes
  components/*            — buttons, cards/panels, forms, status-badge,
                             admin data-grid, admin modal, announcement
                             banner, dividers, social-row
  layout/*                — header/nav, footer, admin nav+dashboard shell
  pages/*                 — home, public menu, admin login/menu/hours

One real content move: .category-panel .panel-head's mobile override
now sits next to its desktop rule in pages/admin-menu instead of inside
the shared admin-data-grid mobile block — keeping it there would have
broken a same-specificity cascade order dependency once split across
files.

Verified behavior-preserving: fresh build's declaration count, class-
token set, and declaration multiset all match the pre-split styles.css
exactly; homepage, menu page, and admin login spot-checked visually and
via computed styles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 19:19:48 -04:00
David DyerandClaude Sonnet 5 5bed636cca Switch homepage review stat from Facebook to Google
- Replace inc/facebook-stats.php with inc/google-reviews.php: fetches
  average star rating + review count from the Places API (New) instead
  of Facebook's Graph API, same cache-to-JSON/fallback-on-failure shape
  as before.
- index.php: stat card now shows the native star rating ("4.8★ average
  rating") instead of a recommend %, sourced from Google. Drops the
  Facebook-followers line entirely — Google's API has no equivalent and
  showing a stale/static number wasn't worth keeping. "Follow on
  Facebook" links elsewhere on the site are untouched.
- config.example.php: swap FB_PAGE_ID/FB_PAGE_ACCESS_TOKEN/
  FB_STATS_CACHE_TTL/FB_FALLBACK_* for GOOGLE_PLACE_ID/
  GOOGLE_PLACES_API_KEY/GOOGLE_STATS_CACHE_TTL/GOOGLE_FALLBACK_RATING/
  GOOGLE_FALLBACK_REVIEW_COUNT (set to the pub's actual 4.8★/71 reviews),
  with setup instructions in comments. config.php (gitignored) updated
  to match.
- Remove the now-dead .stat-row CSS that only existed for the dropped
  followers line; rebuild styles.css.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 18:41:28 -04:00
David DyerandClaude Sonnet 5 2145094e55 Add Sass build pipeline and production deploy script
- Move hand-authored CSS into src/scss/styles.scss, compiled to the
  tracked styles.css via `npm run build` (sass, no source maps).
- Add package.json/package-lock.json with dev tooling: sass, and a
  `dev` script running php + sass --watch + browser-sync together.
- Add scripts/build-dist.js (npm run dist): assembles an allowlisted,
  production-only copy of the site into dist/ (gitignored) for pushing
  to hosting — excludes node_modules, src/, package files, and the
  live db/fitz.sqlite while including config.php.
- .gitignore: ignore node_modules/, styles.css.map, and dist/.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 18:21:01 -04:00
David DyerandClaude Sonnet 5 cd4c6c1969 Add menu enable toggle, user status, and mobile/UI polish
- Admin menu page: mobile item cards now put Actions in the top-right
  corner next to Status, matching the desktop row layout instead of
  stacking at the bottom; same treatment applied to announcements and
  users for consistency across all three admin lists
- Center button label text everywhere via .btn { justify-content: center }
  instead of one-off overrides
- Add a Disable/Enable Menu toggle to the admin menu toolbar; the public
  menu page shows its existing "menu being updated" message while disabled
- Add an Enabled/Disabled status to user accounts, with its own toggle
  badge, guardrails against disabling your own account or the last
  enabled account, and an immediate session kick for anyone disabled
  mid-session
- Fix the announcement banner / hero tagline "·" separators to hide only
  when their neighboring text actually wraps onto a different line,
  instead of a fixed breakpoint or always-on dot

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 14:10:08 -04:00
David DyerandClaude Sonnet 5 eab20899de 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>
2026-09-01 11:18:23 -04:00
David DyerandClaude Sonnet 5 fd928a24cf Rebuild menu admin item/category rows on CSS Grid, refine actions UI
- Replace the <table>-based item and category-header rows with CSS Grid
  containers using named grid-template-areas, so desktop and mobile each
  get their own independent arrangement of the same fields instead of
  mobile being a display:block hack forced onto table markup
- Make the status badge itself the availability toggle (no separate
  "Mark Sold Out" button), fixed-width so toggling never shifts the
  Status column, and shorten its label to "Sold Out"
- Merge the item Tag into the name line as an inline badge instead of
  giving it its own column
- Group the move/edit/delete controls into two stacked button columns
  (was 4-5 buttons wide) and right-align them on mobile

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 22:54:11 -04:00
David DyerandClaude Sonnet 5 40f4c102b4 Add mobile nav toggle, responsive admin tables, and menu reordering
- Public site: replace the always-hidden mobile nav with a hamburger
  toggle (index.php, menu.php, main.js, styles.css)
- Admin: render the Menu/Announcements/Users tables as labeled cards
  below ~760px instead of squeezing descriptions into unreadable
  single-word-per-line columns
- Admin menu: replace the manual "Order" number (which allowed
  duplicates and never reflected in real time) with ↑/↓ buttons that
  reorder items and categories immediately and renumber sort_order
  sequentially on every move, self-healing any stale duplicates

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 22:12:24 -04:00
David Dyer 7c96b952e9 Add password visibility toggle to user and admin forms 2026-08-30 23:07:24 -04:00
David Dyer e68b24e1c7 Add user management to admin dashboard and refine button specificity
- Implement admin user management interface (, , ) with async CRUD and password updating
- Add Users tab link to admin navigation across  and
- Use  in  to lower specificity for native button resets, allowing modifier classes like  to take precedence
2026-08-30 22:30:47 -04:00
David DyerandClaude Sonnet 5 803d02d758 Add photo and two-column layout to the Live Music section
Replaces the text-only Live Music section with a two-column layout
(photo alongside copy) featuring resident musician Jesse Hart, with
a responsive single-column fallback below 50em. Updates the section
copy to name a resident musician plus guest performers rather than
citing a single past artist by name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:05:45 -04:00
David DyerandClaude Sonnet 5 50eeb41375 Dedupe menu admin CSS/markup, restore Mark Sold Out/Available labels
CSS redundancy cleanup on the online menu management feature:

- .menu-category was a byte-for-byte duplicate of the existing .panel
  class (background/border/border-radius/padding); folds it into
  .panel's shared selector group instead, keeping only what's not
  shared (no margin-bottom, since these sit in a grid whose own gap
  already spaces them)
- Several inline style="..." attributes were hand-copied verbatim
  between admin/menu.php's PHP template and admin/menu.js's client-
  side row/panel templates (price/sort/description cell formatting,
  category heading spacing, the empty-state row). Replaced with real
  CSS classes (.item-price-cell, .item-sort-cell, .item-desc-cell,
  .cat-name-heading, .cat-desc-text, .no-items-row td) so the styling
  lives in one place instead of two
- class="font-mono" was referenced four times but never defined
  anywhere in CSS (silently doing nothing); it's now a real one-line
  utility, which also fixes two previously-dead uses of it already
  sitting in admin/index.php
- .form-actions already existed as a class with its own margin-top,
  but every usage (two new modals here, plus a pre-existing one in
  admin/index.php) redundantly re-specified layout via inline style
  with a slightly different margin-top value each time; extended the
  shared class to own the whole pattern and dropped all three inline
  overrides
- Removed a no-op `justify-content: flex-start` (the default value)
  from the new .admin-toolbar

Caught and fixed a regression from the above before it shipped:
removing the inline margin-bottom override on the category heading
exposed that .panel h2's margin-bottom (from the existing panel
styles) has higher specificity than a bare .cat-name-heading class,
so the heading would have rendered with 5x the intended spacing.
Rescoped as .category-panel .cat-name-heading to reliably win.

Also restores the "Mark Sold Out" / "Mark Available" button wording
(both directions of the toggle, in both the PHP-rendered rows and
the two JS render paths) after it had been shortened to just "Sold
Out" / "Available".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:03:22 -04:00
David DyerandClaude Sonnet 5 f1bbf59548 Add online menu management, modernized and hardened
Adds a public menu page (menu.php) and an admin CRUD interface
(admin/menu.php + admin/menu.js) backed by new menu_categories and
menu_items tables, replacing the "menu lives on Facebook" placeholder
on the homepage.

The admin JS/PHP came out of an AI-assisted pass that used outdated
JS (var, .then() chains, string-built HTML) and had some rough edges
security-wise, so this also:

- Rewrites the admin JS to modern ES (const/let, async/await,
  template literals, dataset API) and splits it out of admin/menu.php
  into admin/menu.js for proper separation of concerns
- Adds declare(strict_types=1) and PHP 8 match-based action dispatch
  in place of an if-chain
- Validates category_id/item_id/category_id server-side before use,
  so a bad ID returns a clean JSON error instead of an uncaught
  PDOException or a null item breaking the client
- Rejects non-string POST fields instead of crashing trim() on them
- Replaces the category-save handler's full-page location.reload()
  with in-place DOM updates (panel insert/update, resort by
  sort_order, keep the item modal's category dropdown in sync), so
  every admin action is now AJAX with no reload
- Sets data-name/data-desc via dataset assignment rather than string-
  interpolating them into an HTML attribute, closing a stored-XSS
  path through the category name/description fields

Removes seed-menu.php, a leftover stub with no working code left in
it after the AI's one-time seeding run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 21:35:22 -04:00
David Dyer 21ad6c31cf Add dev.sh to .gitignore to prevent tracking of development scripts 2026-08-30 19:57:06 -04:00
David DyerandClaude Sonnet 5 197047f4ef Dedupe remaining CSS: danger colors, redundant background/cursor
- Add --danger-bg/--danger-text tokens and point .error-msg,
  .alert-error, and .btn-danger at them instead of repeating the
  same rgba/hex literals
- Drop .login-btn's cursor: pointer (already covered by button.btn)
- Drop .btn-outline's background: transparent (already covered by
  button.btn for buttons; default background for anchors)

No visual changes intended.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 23:13:25 -04:00
David Dyer 38b32f69fd Reset button background and border styles in styles.css
- Ensure <button class="btn"> elements default to transparent backgrounds
- Ensure .btn-outline uses transparent background to avoid browser-default user-agent button styling
- Set border: none on .btn-primary
2026-08-29 22:53:35 -04:00
David Dyer bd22299247 Add multi-announcement management system
- Update inc/announcements.php to support listing, fetching by ID, toggling, and deleting multiple announcements
- Upgrade admin/index.php to an announcement management view with data table, status indicators, and create/edit sub-views
- Add admin table and action styles to styles.css without using inline style blocks
- Update index.php to render all currently active banner and hours announcements dynamically
2026-08-29 22:50:19 -04:00
David DyerandClaude Sonnet 5 e4cd4f6cfb Dedupe styles.css and remove remaining inline styles
- Add --font-serif/--font-mono/--font-sans tokens to :root and point
  all ~24 hardcoded font-family declarations at them
- Drop an exact-duplicate .footer-brand .knot rule
- Merge the shared background/border of .stat-card, .card,
  .login-card, and .panel into one grouped selector
- Combine .btn-outline:hover and .nav-cta:hover into one rule
- Move remaining inline style="" attributes in index.php and
  admin/index.php into named classes/rules (.about-grid h2,
  footer address/.social-row spacing, .panel-head,
  .checkbox-label-lg, .field-hint, .form-actions)

No visual changes intended — values carried over as-is.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 22:37:02 -04:00
David Dyer b55c4f294f Consolidate admin styles into styles.css and remove inline style tags
- Move all admin dashboard and login rules into styles.css
- Remove embedded <style> blocks from admin/login.php and admin/index.php
- Clean up redundant preview action button
2026-08-29 22:01:55 -04:00
David Dyer f851e8fd24 Remove unused video file from .gitignore 2026-08-29 21:59:02 -04:00
David Dyer 9b7d9ceccb Add SQLite-backed announcement system and admin portal
- Add SQLite database initialization and management in inc/db.php
- Implement session authentication and CSRF security helpers in inc/auth.php
- Implement announcement querying and saving in inc/announcements.php
- Add management dashboard, login, and logout in admin/
- Protect db/ directory with Apache access controls
2026-08-29 21:57:42 -04:00
David Dyer b9d3262233 Refactor styles to relative rem/em units and enhance accessibility
- Refactor hardcoded px values to rem and em across layout, components, and media queries
- Improve WCAG color contrast by updating --gold-dim token
- Convert hours table to a semantic description list (<dl>)
- Wrap hero logo in h1 for proper heading hierarchy
- Add database ignore rules to .gitignore
2026-08-29 21:57:29 -04:00
59 changed files with 11266 additions and 417 deletions

No files matched your search

+15 -4
View File
@@ -8,7 +8,18 @@ config.php
# Runtime-generated cache (facebook-stats.php writes here) # Runtime-generated cache (facebook-stats.php writes here)
/cache/ /cache/
# Original raw video footage, 27MB, not referenced anywhere on the live # SQLite database file (keep schema/htaccess tracked, ignore live db file)
# site (hero background ended up using assets/brick-texture.png instead). db/fitz.sqlite
# Remove this line if you want the raw footage tracked in git too. db/fitz.sqlite-wal
assets/video.mp4 db/fitz.sqlite-shm
.hintrc
dev.sh
.claude/
# Node development dependencies and Sass source maps
node_modules/
styles.css.map
# Generated deploy folder (npm run dist) — includes config.php, never commit
dist/
+182
View File
@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../inc/auth.php';
require_once __DIR__ . '/../inc/assets.php';
require_once __DIR__ . '/../inc/users.php';
// Any signed-in user can change their own username and/or password here,
// whatever their role. Any change requires the current password.
require_auth();
$user = get_logged_in_user();
$userId = (int) $user['id'];
$errorMsg = '';
function account_post_str(string $key): string {
return is_string($_POST[$key] ?? null) ? $_POST[$key] : '';
}
/**
* Saves whichever of username / password changed, after checking the
* current password once. Returns an error message, or null on success.
*/
function handle_update_account(int $userId, array $user): ?string {
$username = trim(account_post_str('username'));
$newPassword = account_post_str('new_password');
$usernameChanged = $username !== $user['username'];
$passwordChanged = $newPassword !== '';
if (!verify_user_password($userId, account_post_str('current_password'))) {
return 'Your current password is incorrect.';
}
if (!$usernameChanged && !$passwordChanged) {
return 'Nothing to change — edit your username or enter a new password.';
}
// Validate everything before saving anything, so a bad password
// doesn't leave a half-applied username change behind.
if ($usernameChanged) {
if ($error = validate_username($username)) {
return $error;
}
if (username_exists($username, $userId)) {
return 'That username is already taken.';
}
}
if ($passwordChanged) {
if (strlen($newPassword) < MIN_PASSWORD_LENGTH) {
return 'New password must be at least ' . MIN_PASSWORD_LENGTH . ' characters.';
}
if ($newPassword !== account_post_str('confirm_password')) {
return 'New passwords do not match.';
}
}
if ($usernameChanged) {
update_user($userId, $username, $user['role']);
$_SESSION['admin_username'] = $username;
}
if ($passwordChanged) {
set_user_password($userId, $newPassword);
// New session ID after a credential change, so any copy of the old
// session cookie stops working.
session_regenerate_id(true);
}
return null;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf_token(account_post_str('csrf_token'))) {
$errorMsg = 'Session expired or invalid security token. Please try again.';
} else {
$errorMsg = handle_update_account($userId, $user) ?? '';
}
if ($errorMsg === '') {
header('Location: account.php?saved=1');
exit;
}
}
$justSaved = !$_POST && isset($_GET['saved']);
$isAdmin = ($user['role'] ?? '') === 'admin';
// After a failed save, keep the username they typed in the field.
$usernameValue = $_POST ? trim(account_post_str('username')) : $user['username'];
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My Account | 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<?= asset_version('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>
<?php if ($isAdmin): ?>
<a href="users.php">Users</a>
<?php endif; ?>
<a href="hours.php">Hours</a>
</div>
<div class="admin-user">
<a href="../" target="_blank" rel="noopener">View Public Site ↗</a>
<a href="account.php" class="admin-account-link active" title="My Account"><?= htmlspecialchars($user['username']) ?></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">Account</p>
<h1>My Account</h1>
</div>
<?php if ($errorMsg !== ''): ?>
<div class="alert-error" role="alert"><?= htmlspecialchars($errorMsg) ?></div>
<?php elseif ($justSaved): ?>
<div class="alert-success" role="status">Account updated.</div>
<?php endif; ?>
<div class="panel account-panel">
<div class="panel-head">
<h2>
<span>👤 Account Details</span>
</h2>
</div>
<p class="muted">Role: <?= htmlspecialchars(ucfirst((string) $user['role'])) ?></p>
<form method="POST" action="account.php">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
<div class="form-grid">
<div class="form-group">
<label for="account-username">Username</label>
<input type="text" id="account-username" name="username" class="form-control" autocomplete="username" value="<?= htmlspecialchars($usernameValue) ?>" required>
</div>
<div class="form-group">
<label for="account-current">Current Password</label>
<input type="password" id="account-current" name="current_password" class="form-control" autocomplete="current-password" required>
</div>
<div class="form-group">
<label for="account-new">New Password</label>
<input type="password" id="account-new" name="new_password" class="form-control" autocomplete="new-password" minlength="<?= MIN_PASSWORD_LENGTH ?>">
<p class="muted field-hint">Leave blank to keep your current password.</p>
</div>
<div class="form-group">
<label for="account-confirm">Confirm New Password</label>
<input type="password" id="account-confirm" name="confirm_password" class="form-control" autocomplete="new-password" minlength="<?= MIN_PASSWORD_LENGTH ?>">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
</main>
</body>
</html>
+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();
});
+208
View File
@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../inc/auth.php';
require_once __DIR__ . '/../inc/assets.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<?= asset_version('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>
<?php if (($user['role'] ?? '') === 'admin'): ?>
<a href="users.php">Users</a>
<?php endif; ?>
<a href="hours.php" class="active">Hours</a>
</div>
<div class="admin-user">
<a href="../" target="_blank" rel="noopener">View Public Site ↗</a>
<a href="account.php" class="admin-account-link" title="My Account"><?= htmlspecialchars($user['username']) ?></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>
+44
View File
@@ -0,0 +1,44 @@
// 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;
}
});
// Asks before submitting any form marked .js-confirm-submit (e.g. delete),
// using the message in its data-confirm attribute.
document.addEventListener('submit', (e) => {
const form = e.target;
if (!form.classList.contains('js-confirm-submit')) return;
if (!confirm(form.dataset.confirm || 'Are you sure?')) {
e.preventDefault();
}
});
+326
View File
@@ -0,0 +1,326 @@
<?php
require_once __DIR__ . '/../inc/auth.php';
require_once __DIR__ . '/../inc/assets.php';
require_once __DIR__ . '/../inc/announcements.php';
require_auth();
$user = get_logged_in_user();
$errorMsg = '';
$today = date('Y-m-d');
/**
* 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'] ?? '';
if ($action === 'save_announcement') {
$annId = !empty($_POST['announcement_id']) ? (int)$_POST['announcement_id'] : null;
save_announcement([
'is_active' => isset($_POST['is_active']) ? 1 : 0,
'type' => $_POST['type'] ?? 'holiday',
'headline' => $_POST['headline'] ?? '',
'message' => $_POST['message'] ?? '',
'start_date' => $_POST['start_date'] ?? null,
'end_date' => $_POST['end_date'] ?? null,
'show_banner' => isset($_POST['show_banner']) ? 1 : 0,
'show_in_hours' => isset($_POST['show_in_hours']) ? 1 : 0,
], $annId);
} elseif ($action === 'delete_announcement') {
$annId = (int)($_POST['announcement_id'] ?? 0);
if ($annId > 0) {
delete_announcement($annId);
}
} elseif ($action === 'toggle_active') {
$annId = (int)($_POST['announcement_id'] ?? 0);
if ($annId > 0) {
toggle_announcement_active($annId);
}
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;
}
}
}
}
// Check if editing a specific announcement or creating new
$editingId = isset($_GET['edit']) ? (int)$_GET['edit'] : null;
$isCreatingNew = isset($_GET['new']);
$editItem = null;
if ($editingId) {
$editItem = get_announcement_by_id($editingId);
}
$announcements = get_all_announcements();
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pub Management Dashboard | The Old Fitz</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<?= asset_version('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" class="active">Announcements</a>
<a href="menu.php">Menu</a>
<?php if (($user['role'] ?? '') === 'admin'): ?>
<a href="users.php">Users</a>
<?php endif; ?>
<a href="hours.php">Hours</a>
</div>
<div class="admin-user">
<a href="../" target="_blank" rel="noopener">View Public Site ↗</a>
<a href="account.php" class="admin-account-link" title="My Account"><?= htmlspecialchars($user['username']) ?></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">Content & Operations</p>
<h1>Site Announcements & Alerts</h1>
</div>
<?php if (!empty($errorMsg)): ?>
<div class="alert-error" role="alert"><?= htmlspecialchars($errorMsg) ?></div>
<?php endif; ?>
<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">
<div class="action-stack">
<a href="index.php?edit=<?= (int)$ann['id'] ?>" class="btn btn-outline btn-sm">Edit</a>
<form method="POST" action="index.php" style="display: inline;" class="js-confirm-submit" data-confirm="Are you sure you want to delete this announcement?">
<input type="hidden" name="action" value="delete_announcement">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
<input type="hidden" name="announcement_id" value="<?= (int)$ann['id'] ?>">
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
</div>
</div>
</div>
</div>
<?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>
+19
View File
@@ -0,0 +1,19 @@
// Login page: show/hide password toggle. Both icons are in the markup;
// this just flips which one is hidden, so no HTML is built in JS.
const btn = document.getElementById('btn-toggle-login-password');
const input = document.getElementById('password');
if (btn && input) {
const eyeOpen = btn.querySelector('.js-eye-open');
const eyeOff = btn.querySelector('.js-eye-off');
btn.addEventListener('click', () => {
const show = input.type === 'password';
input.type = show ? 'text' : 'password';
btn.setAttribute('aria-label', show ? 'Hide password' : 'Show password');
eyeOpen.hidden = show;
eyeOff.hidden = !show;
input.focus();
});
}
+86
View File
@@ -0,0 +1,86 @@
<?php
require_once __DIR__ . '/../inc/auth.php';
require_once __DIR__ . '/../inc/login-throttle.php';
require_once __DIR__ . '/../inc/assets.php';
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = is_string($_POST['username'] ?? null) ? $_POST['username'] : '';
$password = is_string($_POST['password'] ?? null) ? $_POST['password'] : '';
$ip = client_ip();
$throttleKey = strtolower(trim($username));
$lockSeconds = login_lock_seconds($ip, $throttleKey);
if ($lockSeconds > 0) {
http_response_code(429);
$error = 'Too many failed attempts. Try again in ' . (int) ceil($lockSeconds / 60) . ' minute(s).';
} elseif (authenticate_user($username, $password)) {
clear_login_failures($ip, $throttleKey);
header('Location: index.php');
exit;
} else {
record_login_failure($ip, $throttleKey);
$error = 'Invalid username or password.';
}
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin Login | The Old Fitz Public House</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:wght@600&family=Karla:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../styles.css<?= asset_version('styles.css') ?>">
</head>
<body class="admin-login-body">
<div class="login-card">
<div class="login-header">
<img src="../assets/knot.png" alt="" class="knot">
<h1>The Old Fitz</h1>
<p>Management Portal</p>
</div>
<?php if (!empty($error)): ?>
<div class="error-msg" role="alert"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<form method="POST" action="login.php">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" class="form-input" required autofocus autocomplete="username">
</div>
<div class="form-group">
<label for="password">Password</label>
<div class="password-input-wrap">
<input type="password" id="password" name="password" class="form-input" required autocomplete="current-password">
<button type="button" class="password-toggle-btn" aria-label="Show password" id="btn-toggle-login-password">
<span class="js-eye-open">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="icon-eye">
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
</svg>
</span>
<span class="js-eye-off" hidden>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="icon-eye-off">
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" />
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" />
<path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" />
<line x1="2" y1="2" x2="22" y2="22" />
</svg>
</span>
</button>
</div>
</div>
<button type="submit" class="btn btn-primary login-btn">Sign In</button>
</form>
<a href="../" class="back-link">← Return to Pub Website</a>
</div>
<script src="login.js" defer></script>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
<?php
require_once __DIR__ . '/../inc/auth.php';
logout_user();
header('Location: login.php');
exit;
+563
View File
@@ -0,0 +1,563 @@
// Menu Management admin page: category/item CRUD via fetch, modal handling.
// The CSRF token is handed off from PHP via <body data-csrf-token="..."> so
// this file stays pure static JS with no server-side templating in it.
const csrfToken = document.body.dataset.csrfToken;
// Item Modal helpers
const itemModal = document.getElementById('modal-item-backdrop');
const catModal = document.getElementById('modal-cat-backdrop');
function openItemModal(itemData = null) {
const title = document.getElementById('modal-item-title');
const idField = document.getElementById('item-field-id');
const nameField = document.getElementById('item-field-name');
const catField = document.getElementById('item-field-cat');
const priceField = document.getElementById('item-field-price');
const badgeField = document.getElementById('item-field-badge');
const descField = document.getElementById('item-field-desc');
const availField = document.getElementById('item-field-available');
if (itemData) {
title.textContent = '✏️ Edit Menu Item';
idField.value = itemData.id || 0;
nameField.value = itemData.name || '';
catField.value = itemData.category_id || catField.options[0]?.value || 0;
priceField.value = itemData.price || '';
badgeField.value = itemData.badge || '';
descField.value = itemData.description || '';
availField.checked = parseInt(itemData.is_available, 10) === 1;
} else {
title.textContent = '✨ Add Menu Item';
idField.value = 0;
nameField.value = '';
priceField.value = '';
badgeField.value = '';
descField.value = '';
availField.checked = true;
}
itemModal.classList.add('is-open');
nameField.focus();
}
function closeItemModal() {
itemModal.classList.remove('is-open');
}
function openCategoryModal(catData = null) {
const title = document.getElementById('modal-cat-title');
const idField = document.getElementById('cat-field-id');
const nameField = document.getElementById('cat-field-name');
const descField = document.getElementById('cat-field-desc');
if (catData) {
title.textContent = '✏️ Edit Category';
idField.value = catData.id || 0;
nameField.value = catData.name || '';
descField.value = catData.description || '';
} else {
title.textContent = '✨ Add Category';
idField.value = 0;
nameField.value = '';
descField.value = '';
}
catModal.classList.add('is-open');
nameField.focus();
}
function closeCategoryModal() {
catModal.classList.remove('is-open');
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text ?? '';
return div.innerHTML;
}
async function postForm(formData) {
const response = await fetch('menu.php', {
method: 'POST',
body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
return response.json();
}
// Applies a menu_enabled state to the toggle button and its alert banner —
// shared by the click handler below and, implicitly, the server-rendered
// initial state (see menu.php), so the two never drift apart.
function applyMenuEnabledState(enabled) {
const btn = document.getElementById('btn-toggle-menu');
btn.textContent = enabled ? 'Disable Menu' : 'Enable Menu';
btn.classList.toggle('btn-danger', enabled);
btn.classList.toggle('btn-outline', !enabled);
btn.dataset.enabled = enabled ? '1' : '0';
document.getElementById('menu-disabled-alert').hidden = enabled;
}
// Modal listeners
document.getElementById('btn-new-item').addEventListener('click', () => openItemModal());
document.getElementById('btn-new-cat').addEventListener('click', () => openCategoryModal());
document.getElementById('btn-toggle-menu').addEventListener('click', async (e) => {
const btn = e.currentTarget;
const isCurrentlyEnabled = btn.dataset.enabled === '1';
// Only the disabling direction needs a confirm — it immediately hides
// the menu from every visitor, where re-enabling it is harmless.
if (isCurrentlyEnabled && !confirm('Hide the menu from the public site? Visitors will see a "menu being updated" message until you re-enable it.')) {
return;
}
btn.disabled = true;
const fd = new FormData();
fd.append('action', 'toggle_menu_enabled');
fd.append('csrf_token', csrfToken);
try {
const data = await postForm(fd);
if (data.success) {
applyMenuEnabledState(data.menu_enabled);
} else {
alert(data.error || 'Failed to update menu visibility.');
}
} catch {
alert('Network error. Please try again.');
} finally {
btn.disabled = false;
}
});
document.getElementById('btn-create-first-cat')?.addEventListener('click', () => openCategoryModal());
document.getElementById('btn-close-item-modal').addEventListener('click', closeItemModal);
document.getElementById('btn-cancel-item').addEventListener('click', closeItemModal);
document.getElementById('btn-close-cat-modal').addEventListener('click', closeCategoryModal);
document.getElementById('btn-cancel-cat').addEventListener('click', closeCategoryModal);
// Form submit: Save Item (Async Fetch)
document.getElementById('form-item').addEventListener('submit', async (e) => {
e.preventDefault();
const saveBtn = document.getElementById('btn-save-item');
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
try {
const res = await postForm(new FormData(e.target));
if (res.success) {
closeItemModal();
updateOrInsertItemRow(res.item, res.is_new);
} else {
alert(res.error || 'Failed to save item.');
}
} catch {
alert('Network error. Please try again.');
} finally {
saveBtn.disabled = false;
saveBtn.textContent = 'Save Item';
}
});
// Form submit: Save Category (Async Fetch)
document.getElementById('form-cat').addEventListener('submit', async (e) => {
e.preventDefault();
const saveBtn = document.getElementById('btn-save-cat');
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
try {
const res = await postForm(new FormData(e.target));
if (res.success) {
closeCategoryModal();
updateOrInsertCategoryPanel(res.category);
} else {
alert(res.error || 'Failed to save category.');
}
} catch {
alert('Network error. Please try again.');
} finally {
saveBtn.disabled = false;
saveBtn.textContent = 'Save Category';
}
});
// Delegate Table Actions: Edit, Toggle Availability, Delete
document.getElementById('categories-container').addEventListener('click', async (e) => {
const { target } = e;
// Edit Item
if (target.classList.contains('btn-edit-item')) {
const row = target.closest('.item-row');
openItemModal(JSON.parse(row.dataset.item));
return;
}
// Edit Category
if (target.classList.contains('btn-edit-cat')) {
openCategoryModal({
id: target.dataset.id,
name: target.dataset.name,
description: target.dataset.desc
});
return;
}
// Move Item Up/Down
if (target.classList.contains('btn-move-item')) {
const direction = target.dataset.direction;
const row = target.closest('.item-row');
const rowsContainer = row.parentElement;
const sibling = direction === 'up' ? row.previousElementSibling : row.nextElementSibling;
if (!sibling) return; // already at this edge; button should be disabled anyway
target.disabled = true;
const fd = new FormData();
fd.append('action', 'move_item');
fd.append('item_id', row.dataset.itemId);
fd.append('direction', direction);
fd.append('csrf_token', csrfToken);
try {
const data = await postForm(fd);
if (!data.success) {
alert(data.error || 'Failed to reorder item.');
target.disabled = false;
return;
}
if (direction === 'up') {
rowsContainer.insertBefore(row, sibling);
} else {
rowsContainer.insertBefore(sibling, row);
}
// Re-derives disabled state from DOM position rather than re-enabling
// `target` unconditionally, since the swap may have just moved it
// onto an edge (e.g. into the first/last slot) where it should stay disabled.
refreshMoveButtonStates(rowsContainer);
} catch {
alert('Network error. Please try again.');
target.disabled = false;
}
return;
}
// Move Category Up/Down
if (target.classList.contains('btn-move-cat')) {
const direction = target.dataset.direction;
const panel = target.closest('.category-panel');
const container = panel.parentElement;
const sibling = direction === 'up' ? panel.previousElementSibling : panel.nextElementSibling;
if (!sibling) return;
target.disabled = true;
const fd = new FormData();
fd.append('action', 'move_category');
fd.append('category_id', target.dataset.id);
fd.append('direction', direction);
fd.append('csrf_token', csrfToken);
try {
const data = await postForm(fd);
if (!data.success) {
alert(data.error || 'Failed to reorder category.');
target.disabled = false;
return;
}
if (direction === 'up') {
container.insertBefore(panel, sibling);
} else {
container.insertBefore(sibling, panel);
}
refreshCategoryMoveButtonStates();
} catch {
alert('Network error. Please try again.');
target.disabled = false;
}
return;
}
// Toggle Availability — the status badge itself is the button now, so
// `target` IS the badge; no separate "Mark Sold Out" control to sync.
if (target.classList.contains('btn-toggle-item')) {
const row = target.closest('.item-row');
target.disabled = true;
const fd = new FormData();
fd.append('action', 'toggle_item');
fd.append('item_id', target.dataset.id);
fd.append('csrf_token', csrfToken);
try {
const data = await postForm(fd);
if (data.success) {
target.className = `status-badge btn-toggle-item ${data.status_class}`;
target.textContent = data.status_badge;
target.title = `Click to mark as ${data.is_available ? 'sold out' : 'available'}`;
const currentData = JSON.parse(row.dataset.item);
currentData.is_available = data.is_available ? 1 : 0;
row.dataset.item = JSON.stringify(currentData);
}
} catch {
alert('Failed to update status.');
} finally {
target.disabled = false;
}
return;
}
// Delete Item
if (target.classList.contains('btn-delete-item')) {
if (!confirm('Are you sure you want to delete this menu item?')) return;
const row = target.closest('.item-row');
const rowsContainer = row?.parentElement;
const fd = new FormData();
fd.append('action', 'delete_item');
fd.append('item_id', target.dataset.id);
fd.append('csrf_token', csrfToken);
try {
const data = await postForm(fd);
if (data.success && row) {
row.style.transition = 'opacity 0.2s ease, transform 0.2s ease';
row.style.opacity = '0';
row.style.transform = 'translateX(-10px)';
setTimeout(() => {
row.remove();
refreshMoveButtonStates(rowsContainer);
}, 200);
}
} catch {
alert('Failed to delete item.');
}
return;
}
// Delete Category
if (target.classList.contains('btn-delete-cat')) {
if (!confirm('Are you sure you want to delete this category and ALL its menu items?')) return;
const panel = document.getElementById(`cat-panel-${target.dataset.id}`);
const fd = new FormData();
fd.append('action', 'delete_category');
fd.append('category_id', target.dataset.id);
fd.append('csrf_token', csrfToken);
try {
const data = await postForm(fd);
if (data.success && panel) {
panel.style.transition = 'opacity 0.2s ease, transform 0.2s ease';
panel.style.opacity = '0';
panel.style.transform = 'translateY(-10px)';
setTimeout(() => {
panel.remove();
refreshCategoryMoveButtonStates();
}, 200);
}
} catch {
alert('Failed to delete category.');
}
}
});
// Sets the up/down move buttons' disabled state from each row's actual DOM
// position, rather than tracking indices by hand — first row can't move up,
// last can't move down, everything in between can do either. Call this
// after any insert/remove/reorder in a category's item-rows container.
function refreshMoveButtonStates(rowsContainer) {
if (!rowsContainer) return;
const rows = [...rowsContainer.querySelectorAll('.item-row[data-item-id]')];
rows.forEach((row, index) => {
const upBtn = row.querySelector('.btn-move-item[data-direction="up"]');
const downBtn = row.querySelector('.btn-move-item[data-direction="down"]');
if (upBtn) upBtn.disabled = index === 0;
if (downBtn) downBtn.disabled = index === rows.length - 1;
});
}
// Same idea as refreshMoveButtonStates(), for category panels.
function refreshCategoryMoveButtonStates() {
const panels = [...document.querySelectorAll('#categories-container .category-panel')];
panels.forEach((panel, index) => {
const upBtn = panel.querySelector('.btn-move-cat[data-direction="up"]');
const downBtn = panel.querySelector('.btn-move-cat[data-direction="down"]');
if (upBtn) upBtn.disabled = index === 0;
if (downBtn) downBtn.disabled = index === panels.length - 1;
});
}
function itemRowHtml(item, isAvail) {
const badgeHtml = item.badge ? `<span class="menu-item-badge">${escapeHtml(item.badge)}</span>` : '';
return `
<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="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>
</div>
<div class="action-stack">
<button type="button" class="btn btn-outline btn-sm btn-edit-item">Edit</button>
<button type="button" class="btn btn-danger btn-sm btn-delete-item" data-id="${item.id}">Delete</button>
</div>
</div></div>
`;
}
function updateOrInsertItemRow(item, isNew) {
const isAvail = parseInt(item.is_available, 10) === 1;
let existingRow = document.getElementById(`item-row-${item.id}`);
// An edit can reassign an item to a different category — when that
// happens the row needs to move grids, not just update in place, so
// detach it here and let it fall through to the insert path below.
if (existingRow && existingRow.closest('.item-rows')?.id !== `item-rows-${item.category_id}`) {
const oldRowsContainer = existingRow.parentElement;
existingRow.remove();
existingRow = null;
refreshMoveButtonStates(oldRowsContainer);
if (oldRowsContainer && !oldRowsContainer.querySelector('.item-row[data-item-id]')) {
oldRowsContainer.innerHTML = '<p class="muted item-grid-empty">No items in this category yet.</p>';
}
}
if (existingRow) {
// Update existing row
existingRow.dataset.item = JSON.stringify(item);
const statusBadge = existingRow.querySelector('.status-badge');
statusBadge.className = `status-badge btn-toggle-item ${isAvail ? 'active' : 'inactive'}`;
statusBadge.textContent = isAvail ? '● Available' : '○ Sold Out';
statusBadge.title = `Click to mark as ${isAvail ? 'sold out' : 'available'}`;
existingRow.querySelector('.item-name-cell').textContent = item.name;
existingRow.querySelector('.item-desc-cell').textContent = item.description || '';
existingRow.querySelector('.item-price-cell').textContent = item.price;
const nameRow = existingRow.querySelector('.item-name-row');
nameRow.querySelector('.menu-item-badge')?.remove();
if (item.badge) {
nameRow.insertAdjacentHTML('beforeend', `<span class="menu-item-badge">${escapeHtml(item.badge)}</span>`);
}
return;
}
// Item was created or moved to this category — it always joins at the
// end (see next_item_sort_order() server-side), so just append it.
const rowsContainer = document.getElementById(`item-rows-${item.category_id}`);
if (!rowsContainer) return;
rowsContainer.querySelector('.item-grid-empty')?.remove();
const row = document.createElement('div');
row.className = 'item-row';
row.id = `item-row-${item.id}`;
row.dataset.itemId = item.id;
row.dataset.item = JSON.stringify(item);
row.innerHTML = itemRowHtml(item, isAvail);
rowsContainer.appendChild(row);
refreshMoveButtonStates(rowsContainer);
}
// Static shell for a category panel. Only text-content positions use
// escapeHtml() here — it does not escape quote characters, so it must never
// be used to build an HTML *attribute* string (see applyCategoryData below,
// which sets data-name/data-desc directly via dataset instead).
function categoryPanelHtml(category) {
return `
<div class="panel-head">
<h2 class="cat-name-heading">
<span>${escapeHtml(category.name)}</span>
</h2>
<p class="muted cat-desc-text">${escapeHtml(category.description || '')}</p>
<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>
</div>
<div class="action-stack">
<button type="button" class="btn btn-outline btn-sm btn-edit-cat">Edit Category</button>
<button type="button" class="btn btn-danger btn-sm btn-delete-cat">Delete Category</button>
</div>
</div>
</div>
<div class="item-grid-wrap">
<div class="item-grid-head" aria-hidden="true">
<span class="col-status">Status</span>
<span class="col-item">Item</span>
<span class="col-price">Price</span>
<span class="col-actions">Actions</span>
</div>
<div class="item-rows" id="item-rows-${category.id}">
<p class="muted item-grid-empty">No items in this category yet.</p>
</div>
</div>
`;
}
// Sets id/data-* on a category panel via direct property assignment (never
// via HTML-string interpolation), so arbitrary characters in the name or
// description can't break out of an attribute.
function applyCategoryData(panel, category) {
panel.id = `cat-panel-${category.id}`;
panel.dataset.catId = category.id;
panel.dataset.sortOrder = category.sort_order || 0;
const editBtn = panel.querySelector('.btn-edit-cat');
editBtn.dataset.id = category.id;
editBtn.dataset.name = category.name;
editBtn.dataset.desc = category.description || '';
panel.querySelector('.btn-delete-cat').dataset.id = category.id;
}
// Keeps category panels in the same order the server would render them in
// (by sort_order, then id) after an in-place create/edit.
function resortCategoryPanels() {
const container = document.getElementById('categories-container');
const panels = [...container.querySelectorAll('.category-panel')];
panels.sort((a, b) => {
const bySortOrder = (Number(a.dataset.sortOrder) || 0) - (Number(b.dataset.sortOrder) || 0);
return bySortOrder !== 0 ? bySortOrder : (Number(a.dataset.catId) || 0) - (Number(b.dataset.catId) || 0);
});
panels.forEach((panel) => container.appendChild(panel));
}
// Keeps the "Menu Category" dropdown in the item modal in sync so a
// newly-created or renamed category is immediately usable without a reload.
function syncCategoryOption(category) {
const select = document.getElementById('item-field-cat');
let option = select.querySelector(`option[value="${category.id}"]`);
if (!option) {
option = document.createElement('option');
option.value = category.id;
select.appendChild(option);
}
option.textContent = category.name;
}
function updateOrInsertCategoryPanel(category) {
document.getElementById('empty-menu-panel')?.remove();
let panel = document.getElementById(`cat-panel-${category.id}`);
if (panel) {
panel.querySelector('.cat-name-heading span').textContent = category.name;
panel.querySelector('.cat-desc-text').textContent = category.description || '';
} else {
panel = document.createElement('div');
panel.className = 'panel category-panel';
panel.innerHTML = categoryPanelHtml(category);
document.getElementById('categories-container').appendChild(panel);
}
applyCategoryData(panel, category);
syncCategoryOption(category);
resortCategoryPanels();
refreshCategoryMoveButtonStates();
}
+425
View File
@@ -0,0 +1,425 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../inc/auth.php';
require_once __DIR__ . '/../inc/assets.php';
require_once __DIR__ . '/../inc/menu.php';
require_auth();
$user = get_logged_in_user();
/**
* Safely read a trimmed string from a POST payload, guarding against
* unexpected non-string input (e.g. a spoofed `name[]=` array field).
*/
function post_str(array $data, string $key, string $default = ''): string
{
$value = $data[$key] ?? $default;
return is_string($value) ? trim($value) : $default;
}
function handle_save_item(array $post): array
{
$categoryId = (int) ($post['category_id'] ?? 0);
if (!category_exists($categoryId)) {
return ['success' => false, 'error' => 'Please choose a valid menu category.'];
}
$name = post_str($post, 'name');
$price = post_str($post, 'price');
if ($name === '' || $price === '') {
return ['success' => false, 'error' => 'Item name and price are required.'];
}
$itemId = !empty($post['item_id']) ? (int) $post['item_id'] : null;
if ($itemId !== null && ($itemId <= 0 || !get_menu_item_by_id($itemId))) {
return ['success' => false, 'error' => 'Menu item not found.'];
}
$savedId = save_menu_item([
'category_id' => $categoryId,
'name' => $name,
'description' => post_str($post, 'description'),
'price' => $price,
'badge' => post_str($post, 'badge'),
'is_available' => isset($post['is_available']) ? 1 : 0,
], $itemId);
return ['success' => true, 'item' => get_menu_item_by_id($savedId), 'is_new' => $itemId === null];
}
function handle_toggle_item(array $post): array
{
$itemId = (int) ($post['item_id'] ?? 0);
if ($itemId <= 0 || !get_menu_item_by_id($itemId)) {
return ['success' => false, 'error' => 'Menu item not found.'];
}
toggle_menu_item_availability($itemId);
$item = get_menu_item_by_id($itemId);
return [
'success' => true,
'is_available' => (bool) $item['is_available'],
'status_badge' => $item['is_available'] ? '● Available' : '○ Sold Out',
'status_class' => $item['is_available'] ? 'active' : 'inactive',
];
}
function handle_move_item(array $post): array
{
$itemId = (int) ($post['item_id'] ?? 0);
$direction = post_str($post, 'direction');
if ($itemId <= 0 || !get_menu_item_by_id($itemId)) {
return ['success' => false, 'error' => 'Menu item not found.'];
}
if (!in_array($direction, ['up', 'down'], true)) {
return ['success' => false, 'error' => 'Invalid direction.'];
}
return move_menu_item($itemId, $direction);
}
function handle_delete_item(array $post): array
{
$itemId = (int) ($post['item_id'] ?? 0);
if ($itemId <= 0) {
return ['success' => false, 'error' => 'Menu item not found.'];
}
delete_menu_item($itemId);
return ['success' => true];
}
function handle_save_category(array $post): array
{
$name = post_str($post, 'name');
if ($name === '') {
return ['success' => false, 'error' => 'Category name is required.'];
}
$catId = !empty($post['category_id']) ? (int) $post['category_id'] : null;
if ($catId !== null && ($catId <= 0 || !get_category_by_id($catId))) {
return ['success' => false, 'error' => 'Category not found.'];
}
$savedId = save_category([
'name' => $name,
'description' => post_str($post, 'description'),
], $catId);
return ['success' => true, 'category' => get_category_by_id($savedId), 'is_new' => $catId === null];
}
function handle_move_category(array $post): array
{
$catId = (int) ($post['category_id'] ?? 0);
$direction = post_str($post, 'direction');
if ($catId <= 0 || !get_category_by_id($catId)) {
return ['success' => false, 'error' => 'Category not found.'];
}
if (!in_array($direction, ['up', 'down'], true)) {
return ['success' => false, 'error' => 'Invalid direction.'];
}
return move_category($catId, $direction);
}
function handle_delete_category(array $post): array
{
$catId = (int) ($post['category_id'] ?? 0);
if ($catId <= 0) {
return ['success' => false, 'error' => 'Category not found.'];
}
delete_category($catId);
return ['success' => true];
}
function handle_toggle_menu_enabled(): array
{
set_menu_enabled(!is_menu_enabled());
return ['success' => true, 'menu_enabled' => is_menu_enabled()];
}
function handle_menu_action(string $action, array $post): array
{
return match ($action) {
'save_item' => handle_save_item($post),
'toggle_item' => handle_toggle_item($post),
'move_item' => handle_move_item($post),
'delete_item' => handle_delete_item($post),
'save_category' => handle_save_category($post),
'move_category' => handle_move_category($post),
'delete_category' => handle_delete_category($post),
'toggle_menu_enabled' => handle_toggle_menu_enabled(),
default => ['success' => false, 'error' => 'Unknown action'],
};
}
// Handle AJAX actions (JSON response)
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
header('Content-Type: application/json; charset=utf-8');
$csrfToken = $_POST['csrf_token'] ?? null;
if (!verify_csrf_token(is_string($csrfToken) ? $csrfToken : null)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Session expired or invalid security token. Please refresh.']);
exit;
}
$action = is_string($_POST['action'] ?? null) ? $_POST['action'] : '';
echo json_encode(handle_menu_action($action, $_POST));
exit;
}
$categories = get_all_categories();
$menuGrouped = get_full_menu(false);
$menuEnabled = is_menu_enabled();
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Menu Management | 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<?= asset_version('styles.css') ?>">
</head>
<body data-csrf-token="<?= htmlspecialchars(get_csrf_token(), ENT_QUOTES, 'UTF-8') ?>">
<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" class="active">Menu</a>
<?php if (($user['role'] ?? '') === 'admin'): ?>
<a href="users.php">Users</a>
<?php endif; ?>
<a href="hours.php">Hours</a>
</div>
<div class="admin-user">
<a href="../" target="_blank" rel="noopener">View Public Site ↗</a>
<a href="account.php" class="admin-account-link" title="My Account"><?= htmlspecialchars($user['username']) ?></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">Food &amp; Drink</p>
<h1>Online Menu Management</h1>
</div>
<div class="admin-toolbar">
<button type="button" class="btn btn-primary btn-sm" id="btn-new-item">+ Add Menu Item</button>
<button type="button" class="btn btn-outline btn-sm" id="btn-new-cat">+ Add Category</button>
<button type="button" class="btn btn-sm <?= $menuEnabled ? 'btn-danger' : 'btn-outline' ?>" id="btn-toggle-menu" data-enabled="<?= $menuEnabled ? '1' : '0' ?>">
<?= $menuEnabled ? 'Disable Menu' : 'Enable Menu' ?>
</button>
</div>
<div class="alert-error" id="menu-disabled-alert" role="status" <?= $menuEnabled ? 'hidden' : '' ?>>
Menu is disabled — the public menu page shows a "menu being updated" message instead of your items.
</div>
<div id="categories-container">
<?php if (empty($menuGrouped)): ?>
<div class="panel" id="empty-menu-panel">
<div class="empty-state">
<p>No categories or menu items yet.</p>
<button type="button" class="btn btn-primary btn-sm" id="btn-create-first-cat">+ Create First Category</button>
</div>
</div>
<?php else: ?>
<?php $catCount = count($menuGrouped); ?>
<?php foreach ($menuGrouped as $catIndex => $category): ?>
<div class="panel category-panel" id="cat-panel-<?= (int) $category['id'] ?>" data-cat-id="<?= (int) $category['id'] ?>" data-sort-order="<?= (int) $category['sort_order'] ?>">
<div class="panel-head">
<h2 class="cat-name-heading">
<span><?= htmlspecialchars($category['name']) ?></span>
</h2>
<p class="muted cat-desc-text"><?= htmlspecialchars($category['description'] ?? '') ?></p>
<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>
</div>
<div class="action-stack">
<button type="button" class="btn btn-outline btn-sm btn-edit-cat"
data-id="<?= (int) $category['id'] ?>"
data-name="<?= htmlspecialchars($category['name']) ?>"
data-desc="<?= htmlspecialchars($category['description'] ?? '') ?>">
Edit Category
</button>
<button type="button" class="btn btn-danger btn-sm btn-delete-cat" data-id="<?= (int) $category['id'] ?>">
Delete Category
</button>
</div>
</div>
</div>
<div class="item-grid-wrap">
<div class="item-grid-head" aria-hidden="true">
<span class="col-status">Status</span>
<span class="col-item">Item</span>
<span class="col-price">Price</span>
<span class="col-actions">Actions</span>
</div>
<div class="item-rows" id="item-rows-<?= (int) $category['id'] ?>">
<?php if (empty($category['items'])): ?>
<p class="muted item-grid-empty">No items in this category yet.</p>
<?php else: ?>
<?php $itemCount = count($category['items']); ?>
<?php foreach ($category['items'] as $itemIndex => $item): ?>
<div class="item-row" id="item-row-<?= (int) $item['id'] ?>" data-item-id="<?= (int) $item['id'] ?>" data-item='<?= htmlspecialchars(json_encode($item), ENT_QUOTES, 'UTF-8') ?>'>
<div data-label="Status">
<button type="button" class="status-badge btn-toggle-item <?= $item['is_available'] ? 'active' : 'inactive' ?>" data-id="<?= (int) $item['id'] ?>" title="Click to mark as <?= $item['is_available'] ? 'sold out' : 'available' ?>">
<?= $item['is_available'] ? '● Available' : '○ Sold Out' ?>
</button>
</div>
<div data-label="Item">
<span class="item-name-row">
<strong class="item-name-cell"><?= htmlspecialchars($item['name']) ?></strong>
<?php if (!empty($item['badge'])): ?>
<span class="menu-item-badge"><?= htmlspecialchars($item['badge']) ?></span>
<?php endif; ?>
</span>
<div class="muted item-desc-cell">
<?= htmlspecialchars($item['description'] ?? '') ?>
</div>
</div>
<div class="font-mono item-price-cell" data-label="Price">
<?= htmlspecialchars($item['price']) ?>
</div>
<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 <?= 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>
</div>
<div class="action-stack">
<button type="button" class="btn btn-outline btn-sm btn-edit-item">Edit</button>
<button type="button" class="btn btn-danger btn-sm btn-delete-item" data-id="<?= (int) $item['id'] ?>">
Delete
</button>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</main>
<!-- Modal Backdrop: Menu Item -->
<div class="admin-modal-backdrop" id="modal-item-backdrop" role="dialog" aria-modal="true" aria-labelledby="modal-item-title">
<div class="admin-modal">
<div class="modal-header">
<h2 id="modal-item-title">✨ Add Menu Item</h2>
<button type="button" class="modal-close" aria-label="Close dialog" id="btn-close-item-modal">&times;</button>
</div>
<form id="form-item">
<input type="hidden" name="action" value="save_item">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
<input type="hidden" name="item_id" id="item-field-id" value="0">
<div class="form-grid">
<div class="form-group full-width">
<label class="checkbox-label checkbox-label-lg">
<input type="checkbox" name="is_available" id="item-field-available" value="1" checked>
<strong>Available (Uncheck to Mark as Sold Out / Hidden)</strong>
</label>
</div>
<div class="form-group">
<label for="item-field-cat">Menu Category</label>
<select id="item-field-cat" name="category_id" class="form-control" required>
<?php foreach ($categories as $cat): ?>
<option value="<?= (int) $cat['id'] ?>"><?= htmlspecialchars($cat['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label for="item-field-name">Item Name</label>
<input type="text" id="item-field-name" name="name" class="form-control" placeholder="e.g. Fish & Chips" required>
</div>
<div class="form-group">
<label for="item-field-price">Price</label>
<input type="text" id="item-field-price" name="price" class="form-control" placeholder="e.g. $15 or $7 / pint" required>
</div>
<div class="form-group">
<label for="item-field-badge">Badge / Tag (Optional)</label>
<input type="text" id="item-field-badge" name="badge" class="form-control" placeholder="e.g. Local Favorite, On Tap">
</div>
<div class="form-group full-width">
<label for="item-field-desc">Description</label>
<textarea id="item-field-desc" name="description" class="form-control" placeholder="Ingredients, preparation, flavor notes..."></textarea>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" id="btn-save-item">Save Item</button>
<button type="button" class="btn btn-outline" id="btn-cancel-item">Cancel</button>
</div>
</form>
</div>
</div>
<!-- Modal Backdrop: Category -->
<div class="admin-modal-backdrop" id="modal-cat-backdrop" role="dialog" aria-modal="true" aria-labelledby="modal-cat-title">
<div class="admin-modal">
<div class="modal-header">
<h2 id="modal-cat-title">✨ Add Category</h2>
<button type="button" class="modal-close" aria-label="Close dialog" id="btn-close-cat-modal">&times;</button>
</div>
<form id="form-cat">
<input type="hidden" name="action" value="save_category">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
<input type="hidden" name="category_id" id="cat-field-id" value="0">
<div class="form-grid">
<div class="form-group full-width">
<label for="cat-field-name">Category Name</label>
<input type="text" id="cat-field-name" name="name" class="form-control" placeholder="e.g. Fare to Share" required>
</div>
<div class="form-group full-width">
<label for="cat-field-desc">Description / Subtitle (Optional)</label>
<input type="text" id="cat-field-desc" name="description" class="form-control" placeholder="e.g. Small bites and shareable plates.">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" id="btn-save-cat">Save Category</button>
<button type="button" class="btn btn-outline" id="btn-cancel-cat">Cancel</button>
</div>
</form>
</div>
</div>
<script src="menu.js" defer></script>
</body>
</html>
+330
View File
@@ -0,0 +1,330 @@
// User Management admin page: create users, change passwords, delete users.
// CSRF token and the current user's id are handed off from PHP via
// <body data-csrf-token="..." data-current-user-id="...">.
const csrfToken = document.body.dataset.csrfToken;
const currentUserId = document.body.dataset.currentUserId;
const userModal = document.getElementById('modal-user-backdrop');
const passwordModal = document.getElementById('modal-password-backdrop');
const editModal = document.getElementById('modal-edit-backdrop');
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text ?? '';
return div.innerHTML;
}
async function postForm(formData) {
const response = await fetch('users.php', {
method: 'POST',
body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
return response.json();
}
const EYE_OPEN_SVG = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="icon-eye">
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
</svg>
`;
const EYE_OFF_SVG = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="icon-eye-off">
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" />
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" />
<path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" />
<line x1="2" y1="2" x2="22" y2="22" />
</svg>
`;
function resetPasswordVisibility(form) {
form.querySelectorAll('.password-toggle-btn').forEach((btn) => {
const input = document.getElementById(btn.dataset.target);
if (input) input.type = 'password';
btn.setAttribute('aria-label', 'Show password');
btn.innerHTML = EYE_OPEN_SVG;
});
}
// Add User modal
function openUserModal() {
const form = document.getElementById('form-user');
form.reset();
resetPasswordVisibility(form);
userModal.classList.add('is-open');
}
// Safari ignores autocomplete="off" on fields labeled "Username" and offers
// saved logins whenever one has focus. The modals used to auto-focus that
// field, and a closed modal left it focused, so the prompt kept popping up.
// Now nothing is auto-focused, and closing a modal drops focus.
function closeModal(modal) {
modal.classList.remove('is-open');
if (modal.contains(document.activeElement)) document.activeElement.blur();
}
function closeUserModal() {
closeModal(userModal);
}
// Change Password modal
function openPasswordModal(userId, username) {
const isSelf = String(userId) === currentUserId;
const form = document.getElementById('form-password');
form.reset();
resetPasswordVisibility(form);
document.getElementById('password-field-user-id').value = userId;
document.getElementById('modal-password-title').textContent = isSelf
? '🔑 Change Your Password'
: `🔑 Change Password for ${username}`;
document.getElementById('password-current-group').style.display = isSelf ? '' : 'none';
passwordModal.classList.add('is-open');
(isSelf ? document.getElementById('password-field-current') : document.getElementById('password-field-new')).focus();
}
function closePasswordModal() {
closeModal(passwordModal);
}
// Edit User modal
function openEditModal(userId, username, role) {
const isSelf = String(userId) === currentUserId;
const form = document.getElementById('form-edit');
const roleSelect = document.getElementById('edit-field-role');
form.reset();
document.getElementById('edit-field-user-id').value = userId;
document.getElementById('edit-field-username').value = username;
roleSelect.value = role;
roleSelect.disabled = isSelf;
document.getElementById('edit-role-self-hint').hidden = !isSelf;
editModal.classList.add('is-open');
}
function closeEditModal() {
closeModal(editModal);
}
function capitalize(text) {
return text.charAt(0).toUpperCase() + text.slice(1);
}
// Updates an existing row's text and data attributes in place after an edit.
function updateUserRow(user) {
const row = document.getElementById(`user-row-${user.id}`);
if (!row) return;
row.querySelector('[data-label="Username"] strong').textContent = user.username;
row.querySelector('[data-label="Role"]').textContent = capitalize(user.role);
row.querySelectorAll('.btn-edit-user, .btn-change-password').forEach((btn) => {
btn.dataset.username = user.username;
});
row.querySelector('.btn-edit-user').dataset.role = user.role;
if (String(user.id) === currentUserId) {
const navName = document.querySelector('.admin-account-link');
if (navName) navName.textContent = user.username;
}
}
document.getElementById('btn-close-edit-modal').addEventListener('click', closeEditModal);
document.getElementById('btn-cancel-edit').addEventListener('click', closeEditModal);
document.getElementById('form-edit').addEventListener('submit', async (e) => {
e.preventDefault();
const saveBtn = document.getElementById('btn-save-edit');
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
try {
const res = await postForm(new FormData(e.target));
if (res.success) {
closeEditModal();
updateUserRow(res.user);
} else {
alert(res.error || 'Failed to update user.');
}
} catch {
alert('Network error. Please try again.');
} finally {
saveBtn.disabled = false;
saveBtn.textContent = 'Save Changes';
}
});
document.getElementById('btn-new-user').addEventListener('click', () => openUserModal());
document.getElementById('btn-close-user-modal').addEventListener('click', closeUserModal);
document.getElementById('btn-cancel-user').addEventListener('click', closeUserModal);
document.getElementById('btn-close-password-modal').addEventListener('click', closePasswordModal);
document.getElementById('btn-cancel-password').addEventListener('click', closePasswordModal);
// Password visibility toggles (works inside both modals)
document.addEventListener('click', (e) => {
const toggleBtn = e.target.closest('.password-toggle-btn');
if (!toggleBtn) return;
const targetId = toggleBtn.dataset.target;
const input = document.getElementById(targetId);
if (!input) return;
const isPassword = input.type === 'password';
input.type = isPassword ? 'text' : 'password';
toggleBtn.setAttribute('aria-label', isPassword ? 'Hide password' : 'Show password');
toggleBtn.innerHTML = isPassword ? EYE_OFF_SVG : EYE_OPEN_SVG;
input.focus();
});
// Create user
document.getElementById('form-user').addEventListener('submit', async (e) => {
e.preventDefault();
const saveBtn = document.getElementById('btn-save-user');
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
try {
const res = await postForm(new FormData(e.target));
if (res.success) {
closeUserModal();
insertUserRow(res.user);
} else {
alert(res.error || 'Failed to create user.');
}
} catch {
alert('Network error. Please try again.');
} finally {
saveBtn.disabled = false;
saveBtn.textContent = 'Create User';
}
});
// Change password
document.getElementById('form-password').addEventListener('submit', async (e) => {
e.preventDefault();
const saveBtn = document.getElementById('btn-save-password');
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
try {
const res = await postForm(new FormData(e.target));
if (res.success) {
closePasswordModal();
alert('Password updated.');
} else {
alert(res.error || 'Failed to update password.');
}
} catch {
alert('Network error. Please try again.');
} finally {
saveBtn.disabled = false;
saveBtn.textContent = 'Update Password';
}
});
// Delegate row actions: toggle Status, Change Password, Delete
document.getElementById('users-rows').addEventListener('click', async (e) => {
const { target } = e;
// The status badge itself is the toggle button — same convention as the
// menu items and announcements lists. Self's badge renders as a plain
// (non-button) span, so this never matches your own row.
if (target.classList.contains('btn-toggle-user')) {
target.disabled = true;
const fd = new FormData();
fd.append('action', 'toggle_user_enabled');
fd.append('user_id', target.dataset.id);
fd.append('csrf_token', csrfToken);
try {
const data = await postForm(fd);
if (data.success) {
target.className = `status-badge btn-toggle-user ${data.status_class}`;
target.textContent = data.status_badge;
target.title = `Click to mark as ${data.is_enabled ? 'disabled' : 'enabled'}`;
} else {
alert(data.error || 'Failed to update user status.');
}
} catch {
alert('Network error. Please try again.');
} finally {
target.disabled = false;
}
return;
}
if (target.classList.contains('btn-edit-user')) {
openEditModal(target.dataset.id, target.dataset.username, target.dataset.role);
return;
}
if (target.classList.contains('btn-change-password')) {
openPasswordModal(target.dataset.id, target.dataset.username);
return;
}
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('.user-row');
const fd = new FormData();
fd.append('action', 'delete_user');
fd.append('user_id', target.dataset.id);
fd.append('csrf_token', csrfToken);
try {
const data = await postForm(fd);
if (data.success && row) {
row.style.transition = 'opacity 0.2s ease, transform 0.2s ease';
row.style.opacity = '0';
row.style.transform = 'translateX(-10px)';
setTimeout(() => row.remove(), 200);
} else if (!data.success) {
alert(data.error || 'Failed to delete user.');
}
} catch {
alert('Network error. Please try again.');
}
}
});
function insertUserRow(user) {
const rowsContainer = document.getElementById('users-rows');
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.
// A user you just created is never "you" — the current admin's own row
// already exists — so its Status badge always renders as the clickable
// toggle, never the plain self span (see users.php for that variant).
row.innerHTML = `
<div data-label="Status">
<button type="button" class="status-badge btn-toggle-user active" data-id="${user.id}" title="Click to mark as disabled">● Enabled</button>
</div>
<div data-label="Username"><strong>${escapeHtml(user.username)}</strong></div>
<div data-label="Role">${escapeHtml(user.role.charAt(0).toUpperCase() + user.role.slice(1))}</div>
<div class="font-mono timestamp-cell" data-label="Created">${escapeHtml(user.created_at)}</div>
<div data-label="Actions"><div class="row-actions"><div class="action-stack">
<button type="button" class="btn btn-outline btn-sm btn-edit-user" data-id="${user.id}">Edit</button>
<button type="button" class="btn btn-outline btn-sm btn-change-password" data-id="${user.id}">Change Password</button>
<button type="button" class="btn btn-danger btn-sm btn-delete-user" data-id="${user.id}">Delete</button>
</div></div></div>
`;
row.querySelector('.btn-change-password').dataset.username = user.username;
row.querySelector('.btn-edit-user').dataset.username = user.username;
row.querySelector('.btn-edit-user').dataset.role = user.role;
rowsContainer.appendChild(row);
}
+469
View File
@@ -0,0 +1,469 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../inc/auth.php';
require_once __DIR__ . '/../inc/assets.php';
require_once __DIR__ . '/../inc/users.php';
// User management is admin-only. Other pages don't check roles yet —
// what manager and staff can do is still to be decided with the owner.
require_role('admin');
$user = get_logged_in_user();
// Keeps the stored role to a known set instead of trusting arbitrary POST
// input. Anything unrecognized falls back to the least-privileged role.
const ALLOWED_ROLES = ['admin', 'manager', 'staff'];
const DEFAULT_ROLE = 'staff';
/**
* Safely read a trimmed string from a POST payload, guarding against
* unexpected non-string input (e.g. a spoofed `name[]=` array field).
*/
function post_str(array $data, string $key, string $default = ''): string {
$value = $data[$key] ?? $default;
return is_string($value) ? trim($value) : $default;
}
function post_role(array $data): string {
$role = post_str($data, 'role', DEFAULT_ROLE);
return in_array($role, ALLOWED_ROLES, true) ? $role : DEFAULT_ROLE;
}
function handle_create_user(array $post, array $currentUser): array {
$username = post_str($post, 'username');
$password = post_str($post, 'password');
$confirm = post_str($post, 'confirm_password');
if ($error = validate_username($username)) {
return ['success' => false, 'error' => $error];
}
if (username_exists($username)) {
return ['success' => false, 'error' => 'That username is already taken.'];
}
if (strlen($password) < MIN_PASSWORD_LENGTH) {
return ['success' => false, 'error' => 'Password must be at least ' . MIN_PASSWORD_LENGTH . ' characters.'];
}
if ($password !== $confirm) {
return ['success' => false, 'error' => 'Passwords do not match.'];
}
$newId = create_user($username, $password, post_role($post));
return ['success' => true, 'user' => get_user_by_id($newId)];
}
function handle_edit_user(array $post, array $currentUser): array {
$targetId = (int) ($post['user_id'] ?? 0);
$target = $targetId > 0 ? get_user_by_id($targetId) : null;
if (!$target) {
return ['success' => false, 'error' => 'User not found.'];
}
$username = post_str($post, 'username');
$isSelf = $targetId === (int) $currentUser['id'];
if ($error = validate_username($username)) {
return ['success' => false, 'error' => $error];
}
if (username_exists($username, $targetId)) {
return ['success' => false, 'error' => 'That username is already taken.'];
}
// You can't change your own role: an admin demoting themselves could
// leave nobody able to manage users. Another admin has to do it.
if ($isSelf) {
$role = $target['role'];
} else {
$role = post_str($post, 'role');
if (!in_array($role, ALLOWED_ROLES, true)) {
return ['success' => false, 'error' => 'Invalid role.'];
}
}
update_user($targetId, $username, $role);
if ($isSelf) {
$_SESSION['admin_username'] = $username;
}
return ['success' => true, 'user' => get_user_by_id($targetId)];
}
function handle_change_password(array $post, array $currentUser): array {
$targetId = (int) ($post['user_id'] ?? 0);
$target = $targetId > 0 ? get_user_by_id($targetId) : null;
if (!$target) {
return ['success' => false, 'error' => 'User not found.'];
}
$newPassword = post_str($post, 'new_password');
$confirm = post_str($post, 'confirm_password');
$isSelf = $targetId === (int) $currentUser['id'];
if ($isSelf) {
$currentPassword = post_str($post, 'current_password');
if (!verify_user_password($targetId, $currentPassword)) {
return ['success' => false, 'error' => 'Your current password is incorrect.'];
}
}
if (strlen($newPassword) < MIN_PASSWORD_LENGTH) {
return ['success' => false, 'error' => 'Password must be at least ' . MIN_PASSWORD_LENGTH . ' characters.'];
}
if ($newPassword !== $confirm) {
return ['success' => false, 'error' => 'Passwords do not match.'];
}
set_user_password($targetId, $newPassword);
return ['success' => true];
}
function handle_toggle_user_enabled(array $post, array $currentUser): array {
$targetId = (int) ($post['user_id'] ?? 0);
$target = $targetId > 0 ? get_user_by_id($targetId) : null;
if (!$target) {
return ['success' => false, 'error' => 'User not found.'];
}
if ($targetId === (int) $currentUser['id']) {
return ['success' => false, 'error' => "You can't disable your own account while logged in as it."];
}
$currentlyEnabled = (int) $target['is_enabled'] === 1;
if ($currentlyEnabled && count_enabled_users() <= 1) {
return ['success' => false, 'error' => 'At least one user account must remain enabled.'];
}
toggle_user_enabled($targetId);
$isEnabled = (int) get_user_by_id($targetId)['is_enabled'] === 1;
return [
'success' => true,
'is_enabled' => $isEnabled,
'status_badge' => $isEnabled ? '● Enabled' : '○ Disabled',
'status_class' => $isEnabled ? 'active' : 'inactive',
];
}
function handle_delete_user(array $post, array $currentUser): array {
$targetId = (int) ($post['user_id'] ?? 0);
if ($targetId <= 0 || !get_user_by_id($targetId)) {
return ['success' => false, 'error' => 'User not found.'];
}
if ($targetId === (int) $currentUser['id']) {
return ['success' => false, 'error' => "You can't delete your own account while logged in as it."];
}
if (count_users() <= 1) {
return ['success' => false, 'error' => 'At least one user account must remain.'];
}
delete_user($targetId);
return ['success' => true];
}
function handle_users_action(string $action, array $post, array $currentUser): array {
return match ($action) {
'create_user' => handle_create_user($post, $currentUser),
'edit_user' => handle_edit_user($post, $currentUser),
'change_password' => handle_change_password($post, $currentUser),
'toggle_user_enabled' => handle_toggle_user_enabled($post, $currentUser),
'delete_user' => handle_delete_user($post, $currentUser),
default => ['success' => false, 'error' => 'Unknown action'],
};
}
// Handle AJAX actions (JSON response)
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
header('Content-Type: application/json; charset=utf-8');
$csrfToken = $_POST['csrf_token'] ?? null;
if (!verify_csrf_token(is_string($csrfToken) ? $csrfToken : null)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Session expired or invalid security token. Please refresh.']);
exit;
}
$action = is_string($_POST['action'] ?? null) ? $_POST['action'] : '';
echo json_encode(handle_users_action($action, $_POST, $user));
exit;
}
$users = get_all_users();
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>User Management | 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<?= asset_version('styles.css') ?>">
</head>
<body data-csrf-token="<?= htmlspecialchars(get_csrf_token(), ENT_QUOTES, 'UTF-8') ?>" data-current-user-id="<?= (int) $user['id'] ?>">
<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" class="active">Users</a>
<a href="hours.php">Hours</a>
</div>
<div class="admin-user">
<a href="../" target="_blank" rel="noopener">View Public Site ↗</a>
<a href="account.php" class="admin-account-link" title="My Account"><?= htmlspecialchars($user['username']) ?></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">Access</p>
<h1>User Management</h1>
</div>
<div class="admin-toolbar">
<button type="button" class="btn btn-primary btn-sm" id="btn-new-user">+ Add User</button>
</div>
<div class="panel">
<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-status">Status</span>
<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): ?>
<?php $isSelf = (int) $row['id'] === (int) $user['id']; ?>
<?php $isEnabled = (int) $row['is_enabled'] === 1; ?>
<div class="user-row" id="user-row-<?= (int) $row['id'] ?>" data-user-id="<?= (int) $row['id'] ?>">
<div data-label="Status">
<?php if ($isSelf): ?>
<span class="status-badge <?= $isEnabled ? 'active' : 'inactive' ?>" title="You can't disable your own account.">
<?= $isEnabled ? '● Enabled' : '○ Disabled' ?>
</span>
<?php else: ?>
<button type="button" class="status-badge btn-toggle-user <?= $isEnabled ? 'active' : 'inactive' ?>" data-id="<?= (int) $row['id'] ?>" title="Click to mark as <?= $isEnabled ? 'disabled' : 'enabled' ?>">
<?= $isEnabled ? '● Enabled' : '○ Disabled' ?>
</button>
<?php endif; ?>
</div>
<div data-label="Username">
<strong><?= htmlspecialchars($row['username']) ?></strong>
<?php if ($isSelf): ?>
<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">
<div class="action-stack">
<button type="button" class="btn btn-outline btn-sm btn-edit-user"
data-id="<?= (int) $row['id'] ?>"
data-username="<?= htmlspecialchars($row['username']) ?>"
data-role="<?= htmlspecialchars($row['role']) ?>">
Edit
</button>
<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 (!$isSelf): ?>
<button type="button" class="btn btn-danger btn-sm btn-delete-user" data-id="<?= (int) $row['id'] ?>">
Delete
</button>
<?php endif; ?>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
</main>
<!-- Modal Backdrop: Add User -->
<div class="admin-modal-backdrop" id="modal-user-backdrop" role="dialog" aria-modal="true" aria-labelledby="modal-user-title">
<div class="admin-modal">
<div class="modal-header">
<h2 id="modal-user-title">✨ Add User</h2>
<button type="button" class="modal-close" aria-label="Close dialog" id="btn-close-user-modal">&times;</button>
</div>
<form id="form-user">
<input type="hidden" name="action" value="create_user">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
<div class="form-grid">
<div class="form-group full-width">
<label for="user-field-username">Username</label>
<input type="text" id="user-field-username" name="username" class="form-control" placeholder="e.g. jsmith" autocomplete="off" required>
</div>
<div class="form-group full-width">
<label for="user-field-role">Role</label>
<select id="user-field-role" name="role" class="form-control">
<option value="admin">Admin</option>
<option value="manager">Manager</option>
<option value="staff">Staff</option>
</select>
<p class="muted field-hint">Only admins can manage users. Other permissions are still to be decided.</p>
</div>
<div class="form-group">
<label for="user-field-password">Password</label>
<div class="password-input-wrap">
<input type="password" id="user-field-password" name="password" class="form-control" autocomplete="new-password" minlength="<?= MIN_PASSWORD_LENGTH ?>" required>
<button type="button" class="password-toggle-btn" aria-label="Show password" data-target="user-field-password">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="icon-eye">
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
</svg>
</button>
</div>
</div>
<div class="form-group">
<label for="user-field-confirm">Confirm Password</label>
<div class="password-input-wrap">
<input type="password" id="user-field-confirm" name="confirm_password" class="form-control" autocomplete="new-password" minlength="<?= MIN_PASSWORD_LENGTH ?>" required>
<button type="button" class="password-toggle-btn" aria-label="Show password" data-target="user-field-confirm">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="icon-eye">
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
</svg>
</button>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" id="btn-save-user">Create User</button>
<button type="button" class="btn btn-outline" id="btn-cancel-user">Cancel</button>
</div>
</form>
</div>
</div>
<!-- Modal Backdrop: Edit User -->
<div class="admin-modal-backdrop" id="modal-edit-backdrop" role="dialog" aria-modal="true" aria-labelledby="modal-edit-title">
<div class="admin-modal">
<div class="modal-header">
<h2 id="modal-edit-title">✏️ Edit User</h2>
<button type="button" class="modal-close" aria-label="Close dialog" id="btn-close-edit-modal">&times;</button>
</div>
<form id="form-edit">
<input type="hidden" name="action" value="edit_user">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
<input type="hidden" name="user_id" id="edit-field-user-id" value="0">
<div class="form-grid">
<div class="form-group full-width">
<label for="edit-field-username">Username</label>
<input type="text" id="edit-field-username" name="username" class="form-control" autocomplete="off" required>
</div>
<div class="form-group full-width">
<label for="edit-field-role">Role</label>
<select id="edit-field-role" name="role" class="form-control">
<option value="admin">Admin</option>
<option value="manager">Manager</option>
<option value="staff">Staff</option>
</select>
<p class="muted field-hint" id="edit-role-self-hint" hidden>You can't change your own role. Another admin has to do it.</p>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" id="btn-save-edit">Save Changes</button>
<button type="button" class="btn btn-outline" id="btn-cancel-edit">Cancel</button>
</div>
</form>
</div>
</div>
<!-- Modal Backdrop: Change Password -->
<div class="admin-modal-backdrop" id="modal-password-backdrop" role="dialog" aria-modal="true" aria-labelledby="modal-password-title">
<div class="admin-modal">
<div class="modal-header">
<h2 id="modal-password-title">🔑 Change Password</h2>
<button type="button" class="modal-close" aria-label="Close dialog" id="btn-close-password-modal">&times;</button>
</div>
<form id="form-password">
<input type="hidden" name="action" value="change_password">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
<input type="hidden" name="user_id" id="password-field-user-id" value="0">
<div class="form-grid">
<div class="form-group full-width" id="password-current-group">
<label for="password-field-current">Current Password</label>
<div class="password-input-wrap">
<input type="password" id="password-field-current" name="current_password" class="form-control" autocomplete="current-password">
<button type="button" class="password-toggle-btn" aria-label="Show password" data-target="password-field-current">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="icon-eye">
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
</svg>
</button>
</div>
</div>
<div class="form-group">
<label for="password-field-new">New Password</label>
<div class="password-input-wrap">
<input type="password" id="password-field-new" name="new_password" class="form-control" autocomplete="new-password" minlength="<?= MIN_PASSWORD_LENGTH ?>" required>
<button type="button" class="password-toggle-btn" aria-label="Show password" data-target="password-field-new">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="icon-eye">
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
</svg>
</button>
</div>
</div>
<div class="form-group">
<label for="password-field-confirm">Confirm New Password</label>
<div class="password-input-wrap">
<input type="password" id="password-field-confirm" name="confirm_password" class="form-control" autocomplete="new-password" minlength="<?= MIN_PASSWORD_LENGTH ?>" required>
<button type="button" class="password-toggle-btn" aria-label="Show password" data-target="password-field-confirm">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="icon-eye">
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
</svg>
</button>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" id="btn-save-password">Update Password</button>
<button type="button" class="btn btn-outline" id="btn-cancel-password">Cancel</button>
</div>
</form>
</div>
</div>
<script src="users.js" defer></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

+1
View File
@@ -0,0 +1 @@
Require all denied
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
/**
* CLI-only: create an admin account.
*
* Replaces the old admin/password seed in inc/db.php. Run inside the
* container as www-data, so a newly created fitz.sqlite isn't owned by root:
* docker exec -it -u www-data the-old-fitz php /var/www/html/bin/create-user.php
*/
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
require_once __DIR__ . '/../inc/users.php';
function prompt(string $label, bool $hidden = false): string {
fwrite(STDOUT, $label);
if ($hidden) {
shell_exec('stty -echo');
}
$value = fgets(STDIN);
if ($hidden) {
shell_exec('stty echo');
fwrite(STDOUT, PHP_EOL);
}
return trim((string) $value);
}
$username = prompt('Username: ');
if ($username === '') {
fwrite(STDERR, "Username is required.\n");
exit(1);
}
if (username_exists($username)) {
fwrite(STDERR, "User '$username' already exists.\n");
exit(1);
}
$password = prompt('Password (' . MIN_PASSWORD_LENGTH . '+ chars): ', true);
if (strlen($password) < MIN_PASSWORD_LENGTH) {
fwrite(STDERR, 'Password must be at least ' . MIN_PASSWORD_LENGTH . " characters.\n");
exit(1);
}
if (prompt('Confirm password: ', true) !== $password) {
fwrite(STDERR, "Passwords don't match.\n");
exit(1);
}
$id = create_user($username, $password, 'admin');
fwrite(STDOUT, "Created admin '$username' (id $id).\n");
+27 -26
View File
@@ -2,7 +2,7 @@
/** /**
* Site config template — tracked in git, safe to commit (no real secret * Site config template — tracked in git, safe to commit (no real secret
* here). Copy this file to config.php (gitignored) and fill in your own * here). Copy this file to config.php (gitignored) and fill in your own
* FB_PAGE_ACCESS_TOKEN there. * GOOGLE_PLACES_API_KEY there.
*/ */
// Base URL the site is currently reachable at. Used for social sharing // Base URL the site is currently reachable at. Used for social sharing
@@ -11,33 +11,34 @@
// this to the permanent domain once the site moves there. // this to the permanent domain once the site moves there.
define('SITE_URL', 'https://theoldfitz.daviddyer.me'); define('SITE_URL', 'https://theoldfitz.daviddyer.me');
// The Old Fitz's Facebook Page ID (from the facebook.com/profile.php?id=... links already on the site). // The Old Fitz's Google Place ID — identifies the business for the
define('FB_PAGE_ID', '100086635646712'); // Places API. Look it up with Google's Place ID Finder:
// https://developers.google.com/maps/documentation/places/web-service/place-id
// (search "The Old Fitz Public House, Marion, IN" and copy the ID shown).
define('GOOGLE_PLACE_ID', '');
// Paste a Page Access Token here to make the homepage stats live. // API key for the Places API (New), used to fetch the live star rating
// Until this is filled in, the site quietly uses the fallback numbers below. // and review count for the homepage stat card. Until this is filled in,
// the site quietly uses the fallback numbers below.
// //
// To generate one: // To generate one:
// 1. Go to https://developers.facebook.com/apps and create an app // 1. Go to https://console.cloud.google.com, create (or pick) a
// (type: "Business"), logged in as an admin of The Old Fitz Page. // project, and enable "Places API (New)" under APIs & Services.
// 2. In Graph API Explorer (developers.facebook.com/tools/explorer), // 2. Enable billing on the project — Google requires a card on file,
// pick that app, select "Page Access Token" for The Old Fitz, // but the free monthly credit comfortably covers a rating/review
// requesting the pages_read_engagement permission. // lookup cached for hours at a time on a low-traffic site like
// 3. Use the "Access Token Debugger" (developers.facebook.com/tools/debug/accesstoken) // this one.
// to extend it to a long-lived token (~60 days). Long-lived Page // 3. Create an API key (APIs & Services → Credentials) and restrict
// tokens generally don't expire on their own with regular use, but // it to "Places API (New)" only.
// Meta can still invalidate one (password change, permission define('GOOGLE_PLACES_API_KEY', '');
// changes, app review) — if that happens, this just falls back to
// the numbers below instead of breaking the page.
define('FB_PAGE_ACCESS_TOKEN', '');
// How often to re-fetch from Facebook, in seconds. 6 hours is plenty for // How often to re-fetch from Google, in seconds. 6 hours is plenty for
// numbers that move a few times a month. // a number that moves a few times a month, and stays well inside
define('FB_STATS_CACHE_TTL', 6 * 60 * 60); // Google's caching allowance for Place Details data.
define('GOOGLE_STATS_CACHE_TTL', 6 * 60 * 60);
// Fallback numbers — used whenever there's no token yet, or the Facebook // Fallback numbers — used whenever there's no API key/Place ID yet, or
// API call fails for any reason. Update these by hand if you're not // the Google API call fails for any reason. Update these by hand if
// using the live token. // you're not using the live lookup.
define('FB_FALLBACK_RECOMMEND_PCT', 92); define('GOOGLE_FALLBACK_RATING', 4.8);
define('FB_FALLBACK_REVIEW_COUNT', 11); define('GOOGLE_FALLBACK_REVIEW_COUNT', 71);
define('FB_FALLBACK_FOLLOWERS', 2600);
+1
View File
@@ -0,0 +1 @@
Require all denied
+2 -2
View File
@@ -7,7 +7,7 @@ networks:
services: services:
php-server: php-server:
image: php:8.3-apache image: php:8.5-apache
container_name: the-old-fitz container_name: the-old-fitz
restart: unless-stopped restart: unless-stopped
networks: networks:
@@ -39,7 +39,7 @@ x-casaos:
port_map: "80" port_map: "80"
scheme: http scheme: http
tagline: tagline:
en_us: PHP 8.3 + Apache web server. en_us: PHP 8.5 + Apache web server.
thumbnail: https://www.php.net/images/logos/php-logo.svg thumbnail: https://www.php.net/images/logos/php-logo.svg
title: title:
en_us: PHP Server en_us: PHP Server
+102
View File
@@ -0,0 +1,102 @@
<?php
/**
* Announcements helper functions
*/
require_once __DIR__ . '/db.php';
function get_active_announcements(): array {
$pdo = get_db();
$today = date('Y-m-d');
$sql = "
SELECT * FROM announcements
WHERE is_active = 1
AND (start_date IS NULL OR start_date = '' OR start_date <= :today1)
AND (end_date IS NULL OR end_date = '' OR end_date >= :today2)
ORDER BY id DESC
";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':today1' => $today,
':today2' => $today
]);
return $stmt->fetchAll() ?: [];
}
function get_active_announcement(): ?array {
$all = get_active_announcements();
return !empty($all) ? $all[0] : null;
}
function get_all_announcements(): array {
$pdo = get_db();
$stmt = $pdo->query("SELECT * FROM announcements ORDER BY id DESC");
return $stmt->fetchAll() ?: [];
}
function get_announcement_by_id(int $id): ?array {
$pdo = get_db();
$stmt = $pdo->prepare("SELECT * FROM announcements WHERE id = ?");
$stmt->execute([$id]);
$item = $stmt->fetch();
return $item ?: null;
}
function delete_announcement(int $id): bool {
$pdo = get_db();
$stmt = $pdo->prepare("DELETE FROM announcements WHERE id = ?");
return $stmt->execute([$id]);
}
function toggle_announcement_active(int $id): bool {
$pdo = get_db();
$stmt = $pdo->prepare("UPDATE announcements SET is_active = CASE WHEN is_active = 1 THEN 0 ELSE 1 END, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
return $stmt->execute([$id]);
}
function save_announcement(array $data, ?int $id = null): int {
$pdo = get_db();
$isActive = !empty($data['is_active']) ? 1 : 0;
$type = in_array($data['type'] ?? '', ['holiday', 'special', 'closure']) ? $data['type'] : 'holiday';
$headline = trim($data['headline'] ?? '');
$message = trim($data['message'] ?? '');
$startDate = !empty($data['start_date']) ? trim($data['start_date']) : null;
$endDate = !empty($data['end_date']) ? trim($data['end_date']) : null;
$showBanner = !empty($data['show_banner']) ? 1 : 0;
$showInHours = !empty($data['show_in_hours']) ? 1 : 0;
if ($id && $id > 0) {
$stmt = $pdo->prepare("
UPDATE announcements SET
is_active = ?,
type = ?,
headline = ?,
message = ?,
start_date = ?,
end_date = ?,
show_banner = ?,
show_in_hours = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
");
$stmt->execute([
$isActive, $type, $headline, $message, $startDate, $endDate,
$showBanner, $showInHours, $id
]);
return $id;
} else {
$stmt = $pdo->prepare("
INSERT INTO announcements (
is_active, type, headline, message, start_date, end_date, show_banner, show_in_hours
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
");
$stmt->execute([
$isActive, $type, $headline, $message, $startDate, $endDate,
$showBanner, $showInHours
]);
return (int)$pdo->lastInsertId();
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/**
* Cache-busting query string for a static asset (styles.css, main.js),
* derived from the file's last-modified time.
*
* Without this, browsers — Safari especially, which caches static
* assets far more aggressively than Chrome/Firefox and largely ignores
* heuristic revalidation — can keep serving a stale styles.css/main.js
* indefinitely after a deploy, even on a hard refresh. Appending
* ?v=<mtime> changes the URL every time the file's contents change, so
* it's always treated as a brand new resource rather than a cached one.
*
* Usage: <link rel="stylesheet" href="styles.css<?= asset_version('styles.css') ?>">
* <link rel="stylesheet" href="../styles.css<?= asset_version('styles.css') ?>">
*/
function asset_version(string $relativePath): string
{
$path = dirname(__DIR__) . '/' . ltrim($relativePath, '/');
$mtime = @filemtime($path);
return $mtime !== false ? '?v=' . $mtime : '';
}
+127
View File
@@ -0,0 +1,127 @@
<?php
/**
* Auth & session helper
*/
/**
* Whether the visitor reached us over HTTPS. TLS ends at the reverse proxy
* (NPM or Caddy), so PHP itself only ever sees plain HTTP — trust the
* proxy's X-Forwarded-Proto, but only from a private/loopback address so a
* public client can't claim it.
*/
function request_is_https(): bool {
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
return true;
}
$remote = $_SERVER['REMOTE_ADDR'] ?? '';
$fromProxy = filter_var($remote, FILTER_VALIDATE_IP)
&& !filter_var($remote, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
return $fromProxy && strtolower($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
}
if (session_status() === PHP_SESSION_NONE) {
// Configure secure session cookie defaults. 'secure' follows the
// request scheme so local dev over plain HTTP (including the phone on
// the LAN) can still log in; in production it's always HTTPS.
session_set_cookie_params([
'lifetime' => 86400 * 7, // 7 days
'path' => '/',
'secure' => request_is_https(),
'httponly' => true,
'samesite' => 'Lax'
]);
session_start();
}
require_once __DIR__ . '/db.php';
function is_authenticated(): bool {
return !empty($_SESSION['admin_user_id']);
}
function require_auth(): void {
if (!is_authenticated()) {
header('Location: login.php');
exit;
}
// A user disabled or re-roled mid-session should be affected
// immediately rather than when their session expires — re-check against
// the DB instead of trusting the session's now-possibly-stale copy.
$pdo = get_db();
$stmt = $pdo->prepare("SELECT is_enabled, role FROM users WHERE id = ?");
$stmt->execute([$_SESSION['admin_user_id']]);
$row = $stmt->fetch();
if ($row === false || (int) $row['is_enabled'] !== 1) {
logout_user();
header('Location: login.php');
exit;
}
$_SESSION['admin_role'] = $row['role'];
}
/**
* Like require_auth(), but also requires a specific role. Checked against
* the role require_auth() just re-read from the DB, not the login-time copy.
*/
function require_role(string $role): void {
require_auth();
if (($_SESSION['admin_role'] ?? '') !== $role) {
http_response_code(403);
exit('Forbidden');
}
}
function get_logged_in_user(): ?array {
if (!is_authenticated()) {
return null;
}
return [
'id' => $_SESSION['admin_user_id'],
'username' => $_SESSION['admin_username'] ?? 'admin',
'role' => $_SESSION['admin_role'] ?? 'admin'
];
}
function authenticate_user(string $username, string $password): bool {
$pdo = get_db();
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = ?");
$stmt->execute([trim($username)]);
$user = $stmt->fetch();
if ($user && (int) $user['is_enabled'] === 1 && password_verify($password, $user['password_hash'])) {
session_regenerate_id(true);
$_SESSION['admin_user_id'] = $user['id'];
$_SESSION['admin_username'] = $user['username'];
$_SESSION['admin_role'] = $user['role'];
return true;
}
return false;
}
function logout_user(): void {
$_SESSION = [];
if (ini_get("session.use_cookies")) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$params["path"], $params["domain"],
$params["secure"], $params["httponly"]
);
}
session_destroy();
}
function get_csrf_token(): string {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function verify_csrf_token(?string $token): bool {
if (empty($_SESSION['csrf_token']) || empty($token)) {
return false;
}
return hash_equals($_SESSION['csrf_token'], $token);
}
+166
View File
@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
/**
* Database connection & schema management for SQLite
*/
function get_db(): PDO {
static $pdo = null;
if ($pdo !== null) {
return $pdo;
}
$dbDir = __DIR__ . '/../db';
if (!is_dir($dbDir)) {
mkdir($dbDir, 0755, true);
}
$dbPath = $dbDir . '/fitz.sqlite';
$isNew = !file_exists($dbPath);
$pdo = new PDO('sqlite:' . $dbPath, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_TIMEOUT => 5
]);
// Enable WAL mode for concurrency and performance
$pdo->exec('PRAGMA journal_mode = WAL;');
$pdo->exec('PRAGMA foreign_keys = ON;');
// Initialize tables if needed
init_db_schema($pdo);
return $pdo;
}
function init_db_schema(PDO $pdo): void {
$pdo->exec("
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'admin',
is_enabled INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS announcements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
is_active INTEGER NOT NULL DEFAULT 1,
type TEXT NOT NULL DEFAULT 'holiday',
headline TEXT NOT NULL,
message TEXT NOT NULL,
start_date TEXT NULL,
end_date TEXT NULL,
show_banner INTEGER NOT NULL DEFAULT 1,
show_in_hours INTEGER NOT NULL DEFAULT 1,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS menu_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS menu_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER NOT NULL REFERENCES menu_categories(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT NULL,
price TEXT NOT NULL,
badge TEXT NULL,
is_available INTEGER NOT NULL DEFAULT 1,
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
);
-- Single-row table (id is pinned to 1) for site-wide toggles that
-- don't belong to any one record — starting with whether the
-- public menu page shows its items at all.
CREATE TABLE IF NOT EXISTS site_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
menu_enabled INTEGER NOT NULL DEFAULT 1,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Failed admin logins, used by inc/login-throttle.php. Rows older
-- than the throttle window are pruned on every new failure.
CREATE TABLE IF NOT EXISTS login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL,
username TEXT NOT NULL,
attempted_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_login_attempts_ip ON login_attempts (ip, attempted_at);
CREATE INDEX IF NOT EXISTS idx_login_attempts_username ON login_attempts (username, attempted_at);
");
// users.is_enabled shipped after the initial release — CREATE TABLE IF
// NOT EXISTS above is a no-op against an existing table, so an install
// from before this needs the column added explicitly, once.
$hasIsEnabled = false;
foreach ($pdo->query("PRAGMA table_info(users)") as $col) {
if ($col['name'] === 'is_enabled') {
$hasIsEnabled = true;
break;
}
}
if (!$hasIsEnabled) {
$pdo->exec("ALTER TABLE users ADD COLUMN is_enabled INTEGER NOT NULL DEFAULT 1");
}
// No default admin is seeded — a fresh database has no users until one
// is created from the CLI (run as www-data so the sqlite file stays
// writable by Apache):
// docker exec -it -u www-data the-old-fitz php /var/www/html/bin/create-user.php
// 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]]);
}
}
}
// Seed the singleton settings row (menu on by default) if it doesn't exist yet.
$stmt = $pdo->query("SELECT COUNT(*) as count FROM site_settings");
$settingsCount = $stmt->fetchColumn();
if ($settingsCount == 0) {
$pdo->exec("INSERT INTO site_settings (id, menu_enabled) VALUES (1, 1)");
}
}
-94
View File
@@ -1,94 +0,0 @@
<?php
/**
* Pulls "recommend %", review count, and follower count from The Old
* Fitz's Facebook Page via the Graph API, cached to a local JSON file so
* we don't hit Facebook on every page load. Falls back to the numbers in
* config.php whenever there's no access token yet, or the API call fails
* for any reason — the homepage never breaks because of this.
*
* Known limitation: Facebook's newer thumbs-up/down "recommendation"
* system (the "X% recommend (Y reviews)" wording used on the Page today)
* doesn't have a confirmed, stable Graph API field the way star ratings
* do. This code requests overall_star_rating/rating_count and converts
* a star rating to a percentage if Facebook returns one; if the Page's
* review data isn't exposed that way, recommend_pct/review_count will
* silently stay at the config.php fallback while followers_count still
* goes live. That's expected, not a bug — Meta's review-related API
* access has gotten more restricted over time.
*/
function fritz_facebook_stats(): array {
$fallback = [
'recommend_pct' => FB_FALLBACK_RECOMMEND_PCT,
'review_count' => FB_FALLBACK_REVIEW_COUNT,
'followers' => FB_FALLBACK_FOLLOWERS,
'source' => 'fallback',
'fetched_at' => null,
];
if (FB_PAGE_ACCESS_TOKEN === '') {
return $fallback;
}
$cacheFile = __DIR__ . '/../cache/facebook-stats.json';
if (is_file($cacheFile)) {
$cached = json_decode((string) file_get_contents($cacheFile), true);
if (is_array($cached) && (time() - ($cached['fetched_at'] ?? 0)) < FB_STATS_CACHE_TTL) {
$cached['source'] = 'cache';
return $cached;
}
}
$fresh = fritz_fetch_facebook_stats($fallback);
$fresh['fetched_at'] = time();
$cacheDir = dirname($cacheFile);
if (!is_dir($cacheDir)) {
@mkdir($cacheDir, 0775, true);
}
@file_put_contents($cacheFile, json_encode($fresh));
return $fresh;
}
function fritz_fetch_facebook_stats(array $fallback): array {
if (!function_exists('curl_init')) {
return $fallback;
}
$url = sprintf(
'https://graph.facebook.com/v21.0/%s?fields=followers_count,overall_star_rating,rating_count&access_token=%s',
FB_PAGE_ID,
urlencode(FB_PAGE_ACCESS_TOKEN)
);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
]);
$body = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body === false || $httpCode !== 200) {
return $fallback;
}
$data = json_decode($body, true);
if (!is_array($data) || isset($data['error'])) {
return $fallback;
}
$recommendPct = isset($data['overall_star_rating'])
? (int) round(($data['overall_star_rating'] / 5) * 100)
: $fallback['recommend_pct'];
return [
'recommend_pct' => $recommendPct,
'review_count' => $data['rating_count'] ?? $fallback['review_count'],
'followers' => $data['followers_count'] ?? $fallback['followers'],
'source' => 'live',
];
}
+85
View File
@@ -0,0 +1,85 @@
<?php
/**
* Pulls the average star rating and review count from The Old Fitz's
* Google Business Profile via the Places API (New), cached to a local
* JSON file so we don't hit Google on every page load. Falls back to
* the numbers in config.php whenever there's no API key/Place ID yet,
* or the API call fails for any reason — the homepage never breaks
* because of this.
*/
function fritz_google_reviews(): array {
$fallback = [
'rating' => GOOGLE_FALLBACK_RATING,
'review_count' => GOOGLE_FALLBACK_REVIEW_COUNT,
'source' => 'fallback',
'fetched_at' => null,
];
if (GOOGLE_PLACES_API_KEY === '' || GOOGLE_PLACE_ID === '') {
return $fallback;
}
$cacheFile = __DIR__ . '/../cache/google-reviews.json';
if (is_file($cacheFile)) {
$cached = json_decode((string) file_get_contents($cacheFile), true);
if (is_array($cached) && (time() - ($cached['fetched_at'] ?? 0)) < GOOGLE_STATS_CACHE_TTL) {
$cached['source'] = 'cache';
return $cached;
}
}
$fresh = fritz_fetch_google_reviews($fallback);
$fresh['fetched_at'] = time();
$cacheDir = dirname($cacheFile);
if (!is_dir($cacheDir)) {
@mkdir($cacheDir, 0775, true);
}
@file_put_contents($cacheFile, json_encode($fresh));
return $fresh;
}
function fritz_fetch_google_reviews(array $fallback): array {
if (!function_exists('curl_init')) {
return $fallback;
}
$url = sprintf(
'https://places.googleapis.com/v1/places/%s',
rawurlencode(GOOGLE_PLACE_ID)
);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_HTTPHEADER => [
'X-Goog-Api-Key: ' . GOOGLE_PLACES_API_KEY,
// Field mask is required by the New Places API (400 without
// it) and also determines the billing SKU — rating and
// userRatingCount are "Place Details (New) - Atmosphere".
'X-Goog-FieldMask: rating,userRatingCount',
],
]);
$body = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body === false || $httpCode !== 200) {
return $fallback;
}
$data = json_decode($body, true);
if (!is_array($data) || isset($data['error'])) {
return $fallback;
}
return [
'rating' => $data['rating'] ?? $fallback['rating'],
'review_count' => $data['userRatingCount'] ?? $fallback['review_count'],
'source' => 'live',
];
}
+370
View File
@@ -0,0 +1,370 @@
<?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)];
}
/**
* "4pm" / "4:30pm" — tight, lowercase, no leading-zero-minute clutter.
* Used only by format_time_range_compact() below for the meta-description
* summary's condensed style; the site's on-page hours use format_time_12h
* instead, which keeps a space and uppercase AM/PM.
*/
function format_hour_compact(int $hour, int $minute): string {
$period = $hour >= 12 ? 'pm' : 'am';
$hour12 = $hour % 12;
if ($hour12 === 0) {
$hour12 = 12;
}
if ($minute === 0) {
return "{$hour12}{$period}";
}
return sprintf('%d:%02d%s', $hour12, $minute, $period);
}
/**
* "4–10pm" (or "11am–2pm" when the two ends straddle noon) — same single
* trailing-meridiem rule as format_time_range(), but compact/lowercase to
* match the site's existing meta-description copy style.
*/
function format_time_range_compact(string $openTime, string $closeTime): string {
[$openHour, $openMin] = array_map('intval', explode(':', $openTime));
[$closeHour, $closeMin] = array_map('intval', explode(':', $closeTime));
$openPeriod = $openHour >= 12 ? 'pm' : 'am';
$closePeriod = $closeHour >= 12 ? 'pm' : 'am';
$openLabel = format_hour_compact($openHour, $openMin);
$closeLabel = format_hour_compact($closeHour, $closeMin);
if ($openPeriod === $closePeriod) {
$openLabel = substr($openLabel, 0, -strlen($openPeriod));
}
return "{$openLabel}–{$closeLabel}";
}
/**
* "Thursday" for a single day, "Thursday through Saturday" for a run —
* the full-name equivalent of day_abbr_range(), for prose summaries.
*/
function day_name_range(array $days): string {
if (count($days) === 1) {
return HOURS_DAY_NAMES[$days[0]];
}
return HOURS_DAY_NAMES[$days[0]] . ' through ' . HOURS_DAY_NAMES[end($days)];
}
/**
* "Open Thursday through Saturday, 4–10pm." — the one-line summary used in
* <meta name="description">, og:description and twitter:description, built
* from the same day groupings as the rest of this file so the blurb search
* engines and social previews show always matches whatever's actually set
* in Hours & Visit / the admin hours page, instead of hand-typed copy that
* can silently drift out of date.
*/
function hours_meta_summary(array $hoursByDay): string {
$openGroups = array_values(array_filter(
group_hours_into_ranges($hoursByDay),
fn(array $group): bool => $group['is_open']
));
if (empty($openGroups)) {
return 'Check our Facebook page for current hours.';
}
$parts = [];
foreach ($openGroups as $group) {
$parts[] = day_name_range($group['days']) . ', ' . format_time_range_compact($group['data']['open_time'], $group['data']['close_time']);
}
return 'Open ' . implode('; ', $parts) . '.';
}
/**
* "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);
}
+82
View File
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
/**
* Login brute-force throttle.
*
* Failed logins are recorded per client IP and per username. If either
* passes its limit inside the window, further attempts are refused until
* enough failures age out. A successful login clears that IP + username's
* failures.
*/
require_once __DIR__ . '/db.php';
const LOGIN_WINDOW_SECONDS = 900; // 15 minutes
const LOGIN_MAX_PER_IP = 5;
const LOGIN_MAX_PER_USERNAME = 10;
/**
* The real client IP. Behind a reverse proxy (NPM or Caddy), REMOTE_ADDR
* is the proxy's Docker-network address, so X-Forwarded-For is trusted —
* but only when the request came from a private or loopback address (the
* proxy), never from a public address that could set the header itself.
*
* The rightmost entry is used because it's the one our proxy appended.
* NPM appends to any value the client sent, so leftmost entries can be
* forged; Caddy discards the client's value, so it's the only entry.
*/
function client_ip(): string {
$remote = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$isPublic = filter_var($remote, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
if (!$isPublic) {
$chain = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '');
$forwarded = trim(end($chain));
if (filter_var($forwarded, FILTER_VALIDATE_IP) !== false) {
return $forwarded;
}
}
return $remote;
}
/**
* Seconds until this IP / username may try again, or 0 if not locked.
* Locked means the Nth most recent failure (N = the limit) is still
* inside the window; the lock lifts when that failure ages out.
*/
function login_lock_seconds(string $ip, string $username): int {
$pdo = get_db();
$since = time() - LOGIN_WINDOW_SECONDS;
$checks = [
['ip', $ip, LOGIN_MAX_PER_IP],
['username', $username, LOGIN_MAX_PER_USERNAME],
];
$wait = 0;
foreach ($checks as [$column, $value, $max]) {
$stmt = $pdo->prepare(
"SELECT attempted_at FROM login_attempts
WHERE $column = ? AND attempted_at > ?
ORDER BY attempted_at DESC LIMIT 1 OFFSET ?"
);
$stmt->execute([$value, $since, $max - 1]);
$nth = $stmt->fetchColumn();
if ($nth !== false) {
$wait = max($wait, (int) $nth + LOGIN_WINDOW_SECONDS - time());
}
}
return $wait;
}
function record_login_failure(string $ip, string $username): void {
$pdo = get_db();
$pdo->prepare("INSERT INTO login_attempts (ip, username, attempted_at) VALUES (?, ?, ?)")
->execute([$ip, $username, time()]);
// Prune anything outside the window so the table stays small.
$pdo->prepare("DELETE FROM login_attempts WHERE attempted_at <= ?")
->execute([time() - LOGIN_WINDOW_SECONDS]);
}
function clear_login_failures(string $ip, string $username): void {
get_db()->prepare("DELETE FROM login_attempts WHERE ip = ? AND username = ?")
->execute([$ip, $username]);
}
+249
View File
@@ -0,0 +1,249 @@
<?php
declare(strict_types=1);
/**
* Menu helper functions
*/
require_once __DIR__ . '/db.php';
// Whether the public menu page should show its items at all — separate
// from any individual item's is_available, this is the admin-facing "take
// the whole menu down for maintenance" switch. Defaults to enabled if the
// singleton settings row is somehow missing.
function is_menu_enabled(): bool {
$pdo = get_db();
$value = $pdo->query("SELECT menu_enabled FROM site_settings WHERE id = 1")->fetchColumn();
return $value === false ? true : (bool) $value;
}
function set_menu_enabled(bool $enabled): void {
$pdo = get_db();
$stmt = $pdo->prepare("UPDATE site_settings SET menu_enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1");
$stmt->execute([$enabled ? 1 : 0]);
}
function get_full_menu(bool $availableOnly = false): array {
$pdo = get_db();
$catStmt = $pdo->query("SELECT * FROM menu_categories ORDER BY sort_order ASC, id ASC");
$categories = $catStmt->fetchAll() ?: [];
$itemSql = "SELECT * FROM menu_items";
if ($availableOnly) {
$itemSql .= " WHERE is_available = 1";
}
$itemSql .= " ORDER BY sort_order ASC, id ASC";
$itemStmt = $pdo->query($itemSql);
$items = $itemStmt->fetchAll() ?: [];
// Group items by category_id
$grouped = [];
foreach ($categories as $cat) {
$cat['items'] = [];
$grouped[$cat['id']] = $cat;
}
foreach ($items as $item) {
if (isset($grouped[$item['category_id']])) {
$grouped[$item['category_id']]['items'][] = $item;
}
}
return array_values($grouped);
}
function get_all_categories(): array {
$pdo = get_db();
$stmt = $pdo->query("SELECT * FROM menu_categories ORDER BY sort_order ASC, id ASC");
return $stmt->fetchAll() ?: [];
}
function get_category_by_id(int $id): ?array {
$pdo = get_db();
$stmt = $pdo->prepare("SELECT * FROM menu_categories WHERE id = ?");
$stmt->execute([$id]);
$res = $stmt->fetch();
return $res ?: null;
}
function category_exists(int $id): bool {
if ($id <= 0) {
return false;
}
$pdo = get_db();
$stmt = $pdo->prepare("SELECT 1 FROM menu_categories WHERE id = ?");
$stmt->execute([$id]);
return (bool)$stmt->fetchColumn();
}
// Categories are manually reordered via move_category() rather than a typed
// sort_order field, so a new one always joins at the end of the list.
function next_category_sort_order(): int {
$pdo = get_db();
return (int) $pdo->query("SELECT COALESCE(MAX(sort_order), -1) + 1 FROM menu_categories")->fetchColumn();
}
function save_category(array $data, ?int $id = null): int {
$pdo = get_db();
$name = trim($data['name'] ?? '');
$description = trim($data['description'] ?? '');
if ($id && $id > 0) {
$stmt = $pdo->prepare("UPDATE menu_categories SET name = ?, description = ? WHERE id = ?");
$stmt->execute([$name, $description, $id]);
return $id;
} else {
$sortOrder = next_category_sort_order();
$stmt = $pdo->prepare("INSERT INTO menu_categories (name, description, sort_order) VALUES (?, ?, ?)");
$stmt->execute([$name, $description, $sortOrder]);
return (int)$pdo->lastInsertId();
}
}
/**
* Swap a category with its previous/next neighbor (by current display
* order) and renumber every category's sort_order sequentially from 0.
* The renumbering is what keeps values unique and gap-free — it also
* self-heals any duplicate/stale sort_order left over from before this
* existed, since the new numbers come purely from display position.
*/
function move_category(int $id, string $direction): array {
$pdo = get_db();
$orderedIds = $pdo->query("SELECT id FROM menu_categories ORDER BY sort_order ASC, id ASC")
->fetchAll(PDO::FETCH_COLUMN);
$index = array_search($id, $orderedIds, true);
if ($index === false) {
return ['success' => false, 'error' => 'Category not found.'];
}
$swapWith = $direction === 'up' ? $index - 1 : $index + 1;
if ($swapWith < 0 || $swapWith >= count($orderedIds)) {
return ['success' => false, 'error' => 'Category is already at the ' . ($direction === 'up' ? 'top' : 'bottom') . '.'];
}
[$orderedIds[$index], $orderedIds[$swapWith]] = [$orderedIds[$swapWith], $orderedIds[$index]];
$update = $pdo->prepare("UPDATE menu_categories SET sort_order = ? WHERE id = ?");
$pdo->beginTransaction();
foreach ($orderedIds as $position => $categoryId) {
$update->execute([$position, $categoryId]);
}
$pdo->commit();
return ['success' => true];
}
function delete_category(int $id): bool {
$pdo = get_db();
$stmt = $pdo->prepare("DELETE FROM menu_categories WHERE id = ?");
return $stmt->execute([$id]);
}
function get_menu_item_by_id(int $id): ?array {
$pdo = get_db();
$stmt = $pdo->prepare("SELECT * FROM menu_items WHERE id = ?");
$stmt->execute([$id]);
$res = $stmt->fetch();
return $res ?: null;
}
// Items are manually reordered via move_menu_item() rather than a typed
// sort_order field, so a new one always joins at the end of its category.
function next_item_sort_order(int $categoryId): int {
$pdo = get_db();
$stmt = $pdo->prepare("SELECT COALESCE(MAX(sort_order), -1) + 1 FROM menu_items WHERE category_id = ?");
$stmt->execute([$categoryId]);
return (int) $stmt->fetchColumn();
}
function save_menu_item(array $data, ?int $id = null): int {
$pdo = get_db();
$categoryId = (int)($data['category_id'] ?? 0);
$name = trim($data['name'] ?? '');
$description = trim($data['description'] ?? '');
$price = trim($data['price'] ?? '');
$badge = trim($data['badge'] ?? '');
$isAvailable = !empty($data['is_available']) ? 1 : 0;
if ($id && $id > 0) {
$existing = get_menu_item_by_id($id);
// Keep its existing position when the category is unchanged; if the
// item was reassigned to a different category, it joins at the end
// of that category rather than keeping a sort_order that was only
// ever meaningful among its old category's items.
$sortOrder = ($existing && (int) $existing['category_id'] === $categoryId)
? (int) $existing['sort_order']
: next_item_sort_order($categoryId);
$stmt = $pdo->prepare("
UPDATE menu_items SET
category_id = ?, name = ?, description = ?, price = ?,
badge = ?, is_available = ?, sort_order = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
");
$stmt->execute([$categoryId, $name, $description, $price, $badge, $isAvailable, $sortOrder, $id]);
return $id;
} else {
$sortOrder = next_item_sort_order($categoryId);
$stmt = $pdo->prepare("
INSERT INTO menu_items (category_id, name, description, price, badge, is_available, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?)
");
$stmt->execute([$categoryId, $name, $description, $price, $badge, $isAvailable, $sortOrder]);
return (int)$pdo->lastInsertId();
}
}
/**
* Swap a menu item with its previous/next neighbor within its own category
* (by current display order) and renumber that category's items
* sequentially from 0. See move_category() for why renumbering matters.
*/
function move_menu_item(int $id, string $direction): array {
$item = get_menu_item_by_id($id);
if (!$item) {
return ['success' => false, 'error' => 'Menu item not found.'];
}
$pdo = get_db();
$categoryId = (int) $item['category_id'];
$stmt = $pdo->prepare("SELECT id FROM menu_items WHERE category_id = ? ORDER BY sort_order ASC, id ASC");
$stmt->execute([$categoryId]);
$orderedIds = $stmt->fetchAll(PDO::FETCH_COLUMN);
$index = array_search($id, $orderedIds, true);
if ($index === false) {
return ['success' => false, 'error' => 'Menu item not found.'];
}
$swapWith = $direction === 'up' ? $index - 1 : $index + 1;
if ($swapWith < 0 || $swapWith >= count($orderedIds)) {
return ['success' => false, 'error' => 'Item is already at the ' . ($direction === 'up' ? 'top' : 'bottom') . '.'];
}
[$orderedIds[$index], $orderedIds[$swapWith]] = [$orderedIds[$swapWith], $orderedIds[$index]];
$update = $pdo->prepare("UPDATE menu_items SET sort_order = ? WHERE id = ?");
$pdo->beginTransaction();
foreach ($orderedIds as $position => $itemId) {
$update->execute([$position, $itemId]);
}
$pdo->commit();
return ['success' => true];
}
function toggle_menu_item_availability(int $id): bool {
$pdo = get_db();
$stmt = $pdo->prepare("UPDATE menu_items SET is_available = CASE WHEN is_available = 1 THEN 0 ELSE 1 END, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
return $stmt->execute([$id]);
}
function delete_menu_item(int $id): bool {
$pdo = get_db();
$stmt = $pdo->prepare("DELETE FROM menu_items WHERE id = ?");
return $stmt->execute([$id]);
}
+104
View File
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
/**
* Admin user management helpers.
*
* Every read here explicitly selects columns and leaves password_hash
* out, so it can never accidentally end up in a JSON response or a
* data-* attribute the way a full row dump would.
*/
require_once __DIR__ . '/db.php';
const MIN_PASSWORD_LENGTH = 12;
function get_all_users(): array {
$pdo = get_db();
$stmt = $pdo->query("SELECT id, username, role, is_enabled, created_at FROM users ORDER BY username ASC");
return $stmt->fetchAll() ?: [];
}
function get_user_by_id(int $id): ?array {
$pdo = get_db();
$stmt = $pdo->prepare("SELECT id, username, role, is_enabled, created_at FROM users WHERE id = ?");
$stmt->execute([$id]);
$res = $stmt->fetch();
return $res ?: null;
}
function count_users(): int {
$pdo = get_db();
return (int) $pdo->query("SELECT COUNT(*) FROM users")->fetchColumn();
}
function count_enabled_users(): int {
$pdo = get_db();
return (int) $pdo->query("SELECT COUNT(*) FROM users WHERE is_enabled = 1")->fetchColumn();
}
function toggle_user_enabled(int $id): bool {
$pdo = get_db();
$stmt = $pdo->prepare("UPDATE users SET is_enabled = CASE WHEN is_enabled = 1 THEN 0 ELSE 1 END WHERE id = ?");
return $stmt->execute([$id]);
}
function validate_username(string $username): ?string {
if ($username === '') {
return 'Username is required.';
}
if (strlen($username) < 3 || strlen($username) > 32) {
return 'Username must be between 3 and 32 characters.';
}
if (!preg_match('/^[a-zA-Z0-9_.-]+$/', $username)) {
return 'Username can only contain letters, numbers, and . _ -';
}
return null;
}
function username_exists(string $username, ?int $excludeId = null): bool {
$pdo = get_db();
if ($excludeId !== null) {
$stmt = $pdo->prepare("SELECT 1 FROM users WHERE username = ? AND id != ?");
$stmt->execute([$username, $excludeId]);
} else {
$stmt = $pdo->prepare("SELECT 1 FROM users WHERE username = ?");
$stmt->execute([$username]);
}
return (bool) $stmt->fetchColumn();
}
function create_user(string $username, string $password, string $role): int {
$pdo = get_db();
$hash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $pdo->prepare("INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)");
$stmt->execute([$username, $hash, $role]);
return (int) $pdo->lastInsertId();
}
function update_user(int $id, string $username, string $role): bool {
$pdo = get_db();
$stmt = $pdo->prepare("UPDATE users SET username = ?, role = ? WHERE id = ?");
return $stmt->execute([$username, $role, $id]);
}
function set_user_password(int $id, string $newPassword): bool {
$pdo = get_db();
$hash = password_hash($newPassword, PASSWORD_DEFAULT);
$stmt = $pdo->prepare("UPDATE users SET password_hash = ? WHERE id = ?");
return $stmt->execute([$hash, $id]);
}
function verify_user_password(int $id, string $password): bool {
$pdo = get_db();
$stmt = $pdo->prepare("SELECT password_hash FROM users WHERE id = ?");
$stmt->execute([$id]);
$hash = $stmt->fetchColumn();
return is_string($hash) && password_verify($password, $hash);
}
function delete_user(int $id): bool {
$pdo = get_db();
$stmt = $pdo->prepare("DELETE FROM users WHERE id = ?");
return $stmt->execute([$id]);
}
+95 -69
View File
@@ -1,8 +1,14 @@
<?php <?php
require __DIR__ . '/config.php'; require __DIR__ . '/config.php';
require __DIR__ . '/inc/facebook-stats.php'; require __DIR__ . '/inc/assets.php';
$fbStats = fritz_facebook_stats(); require __DIR__ . '/inc/google-reviews.php';
require __DIR__ . '/inc/announcements.php';
require __DIR__ . '/inc/hours.php';
$googleReviews = fritz_google_reviews();
$activeAnnouncements = get_active_announcements();
$hoursByDay = get_all_hours();
$siteUrl = rtrim(SITE_URL, '/'); $siteUrl = rtrim(SITE_URL, '/');
$hoursSummary = hours_meta_summary($hoursByDay);
?> ?>
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
@@ -10,30 +16,32 @@ $siteUrl = rtrim(SITE_URL, '/');
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>The Old Fitz Public House | Traditional Irish Pub in Marion, Indiana</title> <title>The Old Fitz Public House</title>
<meta name="description" content="The Old Fitz Public House — a traditional Irish pub in Marion, Indiana. Open Thursday through Saturday, 4–10pm. Family friendly, no reservations."> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="styles.css<?= asset_version('styles.css') ?>">
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300;0,9..144,400;0,9..144,500;0,9..144,600;0,9..144,700;1,9..144,400;1,9..144,500&family=Karla:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<meta name="description" content="The Old Fitz Public House — a traditional Irish pub in Marion, Indiana. <?= htmlspecialchars($hoursSummary) ?> Family friendly, no reservations.">
<link rel="canonical" href="https://theoldfitz.daviddyer.me"> <link rel="canonical" href="https://theoldfitz.daviddyer.me">
<meta name="color-scheme" content="light dark">
<!-- Social sharing (Open Graph / Twitter Card) --> <!-- Social sharing (Open Graph / Twitter Card) -->
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:site_name" content="The Old Fitz Public House"> <meta property="og:site_name" content="The Old Fitz Public House">
<meta property="og:title" content="The Old Fitz Public House | Traditional Irish Pub in Marion, Indiana"> <meta property="og:title" content="The Old Fitz Public House | Traditional Irish Pub in Marion, Indiana">
<meta property="og:description" content="A traditional Irish pub in Marion, Indiana. Open Thursday through Saturday, 4–10pm. Family friendly, no reservations."> <meta property="og:description" content="A traditional Irish pub in Marion, Indiana. <?= htmlspecialchars($hoursSummary) ?> Family friendly, no reservations.">
<meta property="og:url" content="<?= $siteUrl ?>/"> <meta property="og:url" content="<?= $siteUrl ?>/">
<meta property="og:image" content="<?= $siteUrl ?>/assets/og-image.png"> <meta property="og:image" content="<?= $siteUrl ?>/assets/og-image.png">
<meta property="og:image:width" content="1200"> <meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630"> <meta property="og:image:height" content="630">
<meta property="og:image:alt" content="The Old Fitz Public House"> <meta property="og:image:alt" content="The Old Fitz Public House">
<meta property="og:locale" content="en_US"> <meta property="og:locale" content="en_US">
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="The Old Fitz Public House | Traditional Irish Pub in Marion, Indiana"> <meta name="twitter:title" content="The Old Fitz Public House | Traditional Irish Pub in Marion, Indiana">
<meta name="twitter:description" content="A traditional Irish pub in Marion, Indiana. Open Thursday through Saturday, 4–10pm. Family friendly, no reservations."> <meta name="twitter:description" content="A traditional Irish pub in Marion, Indiana. <?= htmlspecialchars($hoursSummary) ?> Family friendly, no reservations.">
<meta name="twitter:image" content="<?= $siteUrl ?>/assets/og-image.png"> <meta name="twitter:image" content="<?= $siteUrl ?>/assets/og-image.png">
<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,300;0,9..144,400;0,9..144,500;0,9..144,600;0,9..144,700;1,9..144,400;1,9..144,500&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> </head>
<body> <body>
@@ -42,21 +50,48 @@ $siteUrl = rtrim(SITE_URL, '/');
<header class="site"> <header class="site">
<div class="container nav-inner"> <div class="container nav-inner">
<a class="brand" href="#main"> <a class="brand" href="./">
<img class="knot" src="./assets/knot.png" alt=""> <img class="knot" src="./assets/knot.png" alt="">
<span>THE OLD FITZ</span> <span>THE OLD FITZ</span>
</a> </a>
<nav class="links" aria-label="Primary"> <button type="button" class="nav-toggle" id="nav-toggle" aria-label="Toggle menu" aria-expanded="false" aria-controls="primary-nav">
<svg class="icon-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
<svg class="icon-close" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<line x1="5" y1="5" x2="19" y2="19" />
<line x1="19" y1="5" x2="5" y2="19" />
</svg>
</button>
<nav class="links" id="primary-nav" aria-label="Primary">
<a href="#about">About</a> <a href="#about">About</a>
<a href="#visit">Hours &amp; Visit</a> <a href="#visit">Hours &amp; Visit</a>
<a href="#music">Live Music</a> <a href="#music">Live Music</a>
<a href="#menu">Menu</a> <a href="menu.php">Menu</a>
<a href="#contact">Contact</a>
</nav> </nav>
<a class="nav-cta" href="https://www.google.com/maps/search/?api=1&amp;query=100+N+Washington+St+Marion+IN" target="_blank" rel="noopener">Get Directions</a> <a class="nav-cta" href="https://www.google.com/maps/search/?api=1&amp;query=100+N+Washington+St+Marion+IN" target="_blank" rel="noopener">Get Directions</a>
</div> </div>
</header> </header>
<?php
$bannerAnnouncements = array_filter($activeAnnouncements, function ($a) {
return !empty($a['show_banner']);
});
?>
<?php if (!empty($bannerAnnouncements)): ?>
<?php foreach ($bannerAnnouncements as $banner): ?>
<div class="announcement-banner banner-<?= htmlspecialchars($banner['type']) ?>" role="region" aria-label="Important Announcement">
<div class="container announcement-inner">
<span class="announcement-tag"><?= htmlspecialchars($banner['headline']) ?></span>
<span class="announcement-sep wrap-sep" aria-hidden="true">·</span>
<span class="announcement-text"><?= htmlspecialchars($banner['message']) ?></span>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
<main id="main"> <main id="main">
<section class="hero"> <section class="hero">
@@ -71,11 +106,11 @@ $siteUrl = rtrim(SITE_URL, '/');
<!-- <h1>The Old Fitz</h1> --> <!-- <h1>The Old Fitz</h1> -->
<!-- <p class="sub">Public House</p> --> <!-- <p class="sub">Public House</p> -->
<div class="tagline-row"> <div class="tagline-row">
<span>Traditional Irish Pub</span><span>Family Friendly</span><span>No Reservations</span> <span>Traditional Irish Pub</span><span class="tag-sep wrap-sep" aria-hidden="true">·</span><span>Family Friendly</span><span class="tag-sep wrap-sep" aria-hidden="true">·</span><span>No Reservations</span>
</div> </div>
<div class="hero-ctas"> <div class="hero-ctas">
<a class="btn btn-primary" href="https://www.google.com/maps/search/?api=1&query=100+N+Washington+St+Marion+IN" target="_blank" rel="noopener">Get Directions</a> <a class="btn btn-primary" href="https://www.google.com/maps/search/?api=1&query=100+N+Washington+St+Marion+IN" target="_blank" rel="noopener">Get Directions</a>
<a class="btn btn-outline" href="mailto:info@theoldfitzpub.com">Say Hello</a> <a class="btn btn-outline" href="https://www.facebook.com/profile.php?id=100086635646712" target="_blank" rel="noopener">Say Hello</a>
</div> </div>
</div> </div>
</section> </section>
@@ -85,20 +120,15 @@ $siteUrl = rtrim(SITE_URL, '/');
<div class="about-grid"> <div class="about-grid">
<div class="reveal"> <div class="reveal">
<p class="eyebrow">About the Pub</p> <p class="eyebrow">About the Pub</p>
<h2 style="margin-top:0.6rem;">A proper Irish pub, right on Washington Street.</h2> <h2>A proper Irish pub, right on Washington Street.</h2>
<p class="lede" style="margin-top:1.5rem;">The Old Fitz Public House pours a traditional Irish pub experience in the middle of Marion — no pretense, no reservations, just a good pint and a warm room. Walk in Thursday through Saturday evening, grab a seat at the bar or a table for the family, and settle in.</p> <p class="lede">The Old Fitz Public House pours a traditional Irish pub experience in the middle of Marion — no pretense, no reservations, just a good pint and a warm room. Walk in Thursday through Saturday evening, grab a seat at the bar or a table for the family, and settle in.</p>
<p class="lede">The kitchen runs until 9, the bar stays open later for snacks, and regulars and first-timers get treated the same.</p> <p class="lede">The kitchen runs until 9, the bar stays open later for snacks, and regulars and first-timers get treated the same.</p>
</div> </div>
<div class="stat-card reveal"> <div class="stat-card reveal">
<span class="eyebrow">As the Regulars See It</span> <span class="eyebrow">As the Regulars See It</span>
<div class="stat-big"><?= (int) $fbStats['recommend_pct'] ?>%</div> <div class="stat-big"><?= number_format((float) $googleReviews['rating'], 1) ?>★</div>
<p class="stat-label">recommend The Old Fitz</p> <p class="stat-label">average rating</p>
<p class="stat-source">Source: Facebook reviews (<?= (int) $fbStats['review_count'] ?>)</p> <p class="stat-source">Source: Google reviews (<?= (int) $googleReviews['review_count'] ?>)</p>
<div class="stat-row">
<div class="stat-big"><?= number_format((int) $fbStats['followers']) ?>+</div>
<p class="stat-label">people following along on Facebook</p>
<p class="stat-source">Source: Facebook followers</p>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -113,25 +143,27 @@ $siteUrl = rtrim(SITE_URL, '/');
<div class="visit-grid"> <div class="visit-grid">
<div class="card reveal"> <div class="card reveal">
<h3>On Tap Thu–Sat</h3> <h3>On Tap Thu–Sat</h3>
<?php
$hoursAnnouncements = array_filter($activeAnnouncements, function ($a) {
return !empty($a['show_in_hours']);
});
?>
<?php foreach ($hoursAnnouncements as $hoursNotice): ?>
<div class="hours-alert alert-<?= htmlspecialchars($hoursNotice['type']) ?>">
<strong><?= htmlspecialchars($hoursNotice['headline']) ?>:</strong>
<span><?= htmlspecialchars($hoursNotice['message']) ?></span>
</div>
<?php endforeach; ?>
<dl class="hours-list"> <dl class="hours-list">
<?php foreach (hours_card_rows($hoursByDay) as $row): ?>
<div class="hours-row"> <div class="hours-row">
<dt>Thursday</dt> <dt><?= htmlspecialchars($row['label']) ?></dt>
<dd>4:00 – 10:00 PM</dd> <dd><?= htmlspecialchars($row['text']) ?></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> </div>
<?php endforeach; ?>
</dl> </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>
<div class="card find-us reveal" id="contact"> <div class="card find-us reveal" id="contact">
<svg class="pin" viewBox="0 0 24 24" fill="none" aria-hidden="true"> <svg class="pin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
@@ -169,16 +201,25 @@ $siteUrl = rtrim(SITE_URL, '/');
<section id="music" class="music"> <section id="music" class="music">
<div class="container"> <div class="container">
<div class="rule-row reveal"><span class="line"></span><img class="knot" src="./assets/knot.png" alt=""><span class="line"></span></div> <div class="rule-row reveal"><span class="line"></span><img class="knot" src="./assets/knot.png" alt=""><span class="line"></span></div>
<div class="section-head reveal">
<div class="music-grid">
<div class="music-photo-wrap reveal">
<img src="./assets/jesse-hart.jpg" alt="Jesse Hart performing live acoustic music" class="music-photo" loading="lazy">
</div>
<div class="music-content reveal">
<div class="section-head">
<p class="eyebrow">Live Music</p> <p class="eyebrow">Live Music</p>
<h2>Some nights, there's music.</h2> <h2>Some nights, there's music.</h2>
</div> </div>
<p class="body reveal">The Old Fitz brings in local musicians for live sets on select evenings — recent nights have featured artists like Casey Morris. There's no fixed weekly lineup here, so the surest way to catch a show is to follow along on Facebook or Instagram, where new dates get posted first.</p> <p class="body">The Old Fitz brings in live acoustic sets on select evenings, featuring resident musician <strong>Jesse Hart</strong> alongside guest performers. Because dates are announced as they are booked, the best way to catch an upcoming set is to follow along on Facebook or Instagram, where new dates get posted first.</p>
<div class="hero-ctas reveal"> <div class="hero-ctas">
<a class="btn btn-outline" href="https://www.facebook.com/profile.php?id=100086635646712" target="_blank" rel="noopener">Follow on Facebook</a> <a class="btn btn-outline" href="https://www.facebook.com/profile.php?id=100086635646712" target="_blank" rel="noopener">Follow on Facebook</a>
<a class="btn btn-outline" href="https://www.instagram.com/theoldfitzpub/?hl=en" target="_blank" rel="noopener">Follow on Instagram</a> <a class="btn btn-outline" href="https://www.instagram.com/theoldfitzpub/?hl=en" target="_blank" rel="noopener">Follow on Instagram</a>
</div> </div>
</div> </div>
</div>
</div>
</section> </section>
<section id="menu" class="menu-band"> <section id="menu" class="menu-band">
@@ -188,10 +229,10 @@ $siteUrl = rtrim(SITE_URL, '/');
<p class="eyebrow">The Menu</p> <p class="eyebrow">The Menu</p>
<h2>Pub food and a full bar, Thursday through Saturday.</h2> <h2>Pub food and a full bar, Thursday through Saturday.</h2>
</div> </div>
<p class="body reveal">The current menu lives behind the bar and gets posted on our Facebook page. Stop in, or send us a message and we'll get you sorted before you arrive.</p> <p class="body reveal">We serve traditional Irish staples, shareable plates, and hearty comfort food until 9 PM, with bar snacks staying out later. Explore our full kitchen menu or check Facebook for weekend chalkboard features.</p>
<div class="hero-ctas reveal"> <div class="hero-ctas reveal">
<a class="btn btn-primary" href="https://www.facebook.com/profile.php?id=100086635646712" target="_blank" rel="noopener">See Menu Updates on Facebook</a> <a class="btn btn-primary" href="menu.php">Explore Full Menu →</a>
<a class="btn btn-outline" href="mailto:info@theoldfitzpub.com">Email a Question</a> <a class="btn btn-outline" href="https://www.facebook.com/profile.php?id=100086635646712" target="_blank" rel="noopener">Daily Specials on Facebook</a>
</div> </div>
</div> </div>
</section> </section>
@@ -206,7 +247,7 @@ $siteUrl = rtrim(SITE_URL, '/');
<img class="knot" src="./assets/knot.png" alt=""> <img class="knot" src="./assets/knot.png" alt="">
<span>THE OLD FITZ PUBLIC HOUSE</span> <span>THE OLD FITZ PUBLIC HOUSE</span>
</a> </a>
<address style="margin-top:1rem;"> <address>
100 N Washington Street<br> 100 N Washington Street<br>
Marion, Indiana Marion, Indiana
</address> </address>
@@ -214,15 +255,15 @@ $siteUrl = rtrim(SITE_URL, '/');
<div> <div>
<p class="col-title">Hours</p> <p class="col-title">Hours</p>
<div class="hours-mini"> <div class="hours-mini">
<span>Thu — Sat · 4:00–10:00 PM</span> <?php foreach (hours_footer_lines($hoursByDay) as $line): ?>
<span>Kitchen closes at 9 PM</span> <span><?= htmlspecialchars($line) ?></span>
<span>Sun — Wed · Closed</span> <?php endforeach; ?>
</div> </div>
</div> </div>
<div> <div>
<p class="col-title">Get in Touch</p> <p class="col-title">Get in Touch</p>
<a class="email" href="mailto:info@theoldfitzpub.com">info@theoldfitzpub.com</a> <a class="email" href="mailto:info@theoldfitzpub.com">info@theoldfitzpub.com</a>
<div class="social-row" style="margin-top:1rem;"> <div class="social-row">
<a href="https://www.instagram.com/theoldfitzpub/?hl=en" target="_blank" rel="noopener" aria-label="Instagram"> <a href="https://www.instagram.com/theoldfitzpub/?hl=en" target="_blank" rel="noopener" aria-label="Instagram">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<rect x="3" y="3" width="18" height="18" rx="5" /> <rect x="3" y="3" width="18" height="18" rx="5" />
@@ -245,22 +286,7 @@ $siteUrl = rtrim(SITE_URL, '/');
</div> </div>
</footer> </footer>
<script> <script src="main.js<?= asset_version('main.js') ?>" defer></script>
(function() {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
var els = document.querySelectorAll('.reveal');
if (!('IntersectionObserver' in window)) { els.forEach(function(e) { e.classList.add('is-visible'); }); return; }
var io = new IntersectionObserver(function(entries) {
entries.forEach(function(entry) {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
io.unobserve(entry.target);
}
});
}, { threshold: 0.15 });
els.forEach(function(e) { io.observe(e); });
})();
</script>
</body> </body>
</html> </html>
+119
View File
@@ -0,0 +1,119 @@
// Shared public-site behavior: scroll-reveal animations and the mobile
// nav toggle. Loaded by both index.php and menu.php.
// Reveal-on-scroll
(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const els = document.querySelectorAll('.reveal');
if (!('IntersectionObserver' in window)) {
els.forEach((el) => el.classList.add('is-visible'));
return;
}
const io = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
io.unobserve(entry.target);
}
});
}, { threshold: 0.15 });
els.forEach((el) => io.observe(el));
})();
// Mobile nav toggle
(() => {
const toggle = document.getElementById('nav-toggle');
const nav = document.getElementById('primary-nav');
if (!toggle || !nav) return;
function closeNav() {
nav.classList.remove('is-open');
toggle.setAttribute('aria-expanded', 'false');
}
function openNav() {
nav.classList.add('is-open');
toggle.setAttribute('aria-expanded', 'true');
}
toggle.addEventListener('click', () => {
if (nav.classList.contains('is-open')) {
closeNav();
} else {
openNav();
}
});
// Close after following a link
nav.querySelectorAll('a').forEach((link) => {
link.addEventListener('click', closeNav);
});
// Close on Escape
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeNav();
});
// Close on outside click
document.addEventListener('click', (e) => {
if (!nav.classList.contains('is-open')) return;
if (nav.contains(e.target) || toggle.contains(e.target)) return;
closeNav();
});
})();
// Wrap-aware separator dots (announcement banner, hero tagline row).
// Hides a "·" only when its two flanking elements wrap onto different
// visual lines. There's no CSS way to detect "same line as sibling," and
// a fixed viewport breakpoint can't substitute for it either, since
// wrapping depends on the announcement's own text length, not just
// screen width.
(() => {
const seps = document.querySelectorAll('.wrap-sep');
if (!seps.length) return;
function sameLine(a, b) {
const ra = a.getBoundingClientRect();
const rb = b.getBoundingClientRect();
return ra.top < rb.bottom && rb.top < ra.bottom;
}
// Hiding a separator removes it from flow (see .wrap-sep-hidden in
// styles.css — needs to, so a hidden dot never skews centering by still
// reserving its space), which means one separator's hide/show can shift
// where a *later* one's neighbors land — e.g. the tagline row's second
// dot needs to see the first dot's decision already applied, or it may
// judge its own neighbors' line-sharing from a layout that's since
// changed underneath it. So this deliberately processes separators one
// at a time, in DOM order, applying each decision before measuring the
// next: reset just this one to visible (so its own contribution is
// never argued away by whatever its own current hidden state already
// happens to be — otherwise "hidden" would be self-confirming, since a
// hidden dot's neighbors always look like they share a line), measure,
// decide, apply, then move on with that change already in effect.
function sync() {
seps.forEach((sep) => {
const prev = sep.previousElementSibling;
const next = sep.nextElementSibling;
if (!prev || !next) return;
sep.classList.remove('wrap-sep-hidden');
sep.classList.toggle('wrap-sep-hidden', !sameLine(prev, next));
});
}
sync();
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(sync, 100);
});
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(sync);
}
})();
+218
View File
@@ -0,0 +1,218 @@
<?php
declare(strict_types=1);
require __DIR__ . '/config.php';
require __DIR__ . '/inc/assets.php';
require __DIR__ . '/inc/announcements.php';
require __DIR__ . '/inc/menu.php';
require __DIR__ . '/inc/hours.php';
$activeAnnouncements = get_active_announcements();
$menuEnabled = is_menu_enabled();
$menuCategories = $menuEnabled ? get_full_menu(true) : []; // available items only, or none while the menu is disabled
$hoursByDay = get_all_hours();
$siteUrl = rtrim(SITE_URL, '/');
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pub Menu | The Old Fitz Public House</title>
<meta name="description" content="Explore the authentic food and drink menu at The Old Fitz Public House in Marion, Indiana. Traditional fare, shareable plates, and proper pub classics.">
<link rel="canonical" href="<?= $siteUrl ?>/menu.php">
<meta name="color-scheme" content="light dark">
<!-- Social sharing -->
<meta property="og:type" content="website">
<meta property="og:site_name" content="The Old Fitz Public House">
<meta property="og:title" content="Pub Menu | The Old Fitz Public House">
<meta property="og:description" content="Authentic Irish comfort food, fare to share, and traditional pub staples at The Old Fitz in Marion, Indiana.">
<meta property="og:url" content="<?= $siteUrl ?>/menu.php">
<meta property="og:image" content="<?= $siteUrl ?>/assets/og-image.png">
<meta property="og:locale" content="en_US">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Pub Menu | The Old Fitz Public House">
<meta name="twitter:description" content="Authentic Irish comfort food, fare to share, and traditional pub staples at The Old Fitz in Marion, Indiana.">
<meta name="twitter:image" content="<?= $siteUrl ?>/assets/og-image.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="styles.css<?= asset_version('styles.css') ?>">
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300;0,9..144,400;0,9..144,500;0,9..144,600;0,9..144,700;1,9..144,400;1,9..144,500&family=Karla:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<header class="site">
<div class="container nav-inner">
<a class="brand" href="./">
<img class="knot" src="./assets/knot.png" alt="">
<span>THE OLD FITZ</span>
</a>
<button type="button" class="nav-toggle" id="nav-toggle" aria-label="Toggle menu" aria-expanded="false" aria-controls="primary-nav">
<svg class="icon-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
<svg class="icon-close" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<line x1="5" y1="5" x2="19" y2="19" />
<line x1="19" y1="5" x2="5" y2="19" />
</svg>
</button>
<nav class="links" id="primary-nav" aria-label="Primary">
<a href="./#about">About</a>
<a href="./#visit">Hours &amp; Visit</a>
<a href="./#music">Live Music</a>
<a href="menu.php" class="active">Menu</a>
</nav>
<a class="nav-cta" href="https://www.google.com/maps/search/?api=1&amp;query=100+N+Washington+St+Marion+IN" target="_blank" rel="noopener">Get Directions</a>
</div>
</header>
<?php
$bannerAnnouncements = array_filter($activeAnnouncements, function($a) {
return !empty($a['show_banner']);
});
?>
<?php if (!empty($bannerAnnouncements)): ?>
<?php foreach ($bannerAnnouncements as $banner): ?>
<div class="announcement-banner banner-<?= htmlspecialchars($banner['type']) ?>" role="region" aria-label="Important Announcement">
<div class="container announcement-inner">
<span class="announcement-tag"><?= htmlspecialchars($banner['headline']) ?></span>
<span class="announcement-sep wrap-sep" aria-hidden="true">·</span>
<span class="announcement-text"><?= htmlspecialchars($banner['message']) ?></span>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
<main id="main">
<section class="menu-page-header">
<div class="container" style="text-align: center;">
<p class="eyebrow">The Old Fitz Public House</p>
<h1 style="font-size: clamp(2.4rem, 5vw, 3.8rem); color: var(--gold-bright); margin-top: 0.5rem;">Pub Menu</h1>
<div class="rule-row" style="margin-top: 1.5rem; margin-bottom: 1.5rem;">
<span class="line"></span>
<img class="knot" src="./assets/knot.png" alt="">
<span class="line"></span>
</div>
<p class="muted" style="max-width: 52ch; margin: 0 auto; font-size: 1.05rem;">
Traditional Irish staples, shareable plates, and comforting pub classics cooked fresh. Kitchen serves until 9:00 PM.
</p>
</div>
</section>
<section class="menu-page-content" style="padding-top: 0;">
<div class="container" style="max-width: 62rem;">
<?php if ($menuEnabled && !empty($menuCategories)): ?>
<div class="menu-grid" style="margin-top: 0;">
<?php foreach ($menuCategories as $category): ?>
<?php if (!empty($category['items'])): ?>
<div class="menu-category">
<div class="menu-category-header">
<h3><?= htmlspecialchars($category['name']) ?></h3>
<?php if (!empty($category['description'])): ?>
<p><?= htmlspecialchars($category['description']) ?></p>
<?php endif; ?>
</div>
<div class="menu-items-list">
<?php foreach ($category['items'] as $item): ?>
<div class="menu-item-row">
<div class="menu-item-top">
<span class="menu-item-name">
<?= htmlspecialchars($item['name']) ?>
<?php if (!empty($item['badge'])): ?>
<span class="menu-item-badge"><?= htmlspecialchars($item['badge']) ?></span>
<?php endif; ?>
</span>
<span class="menu-item-price"><?= htmlspecialchars($item['price']) ?></span>
</div>
<?php if (!empty($item['description'])): ?>
<p class="menu-item-desc"><?= htmlspecialchars($item['description']) ?></p>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="panel" style="text-align: center; padding: 3rem 1.5rem;">
<p class="muted">Our kitchen menu is currently being refreshed. Please check our Facebook page for today's specials!</p>
</div>
<?php endif; ?>
<!-- Allergen / Food Safety Disclaimer from the original print menu -->
<div class="menu-disclaimer" style="margin-top: 3.5rem; text-align: center; padding-top: 2rem; border-top: 1px solid var(--rule);">
<p class="muted" style="font-size: 0.8rem; line-height: 1.6; max-width: 68ch; margin: 0 auto;">
Consuming raw or undercooked meats, poultry, seafood, shellfish, or eggs may increase your risk of foodborne illness. Our kitchen handles a variety of ingredients, and our menu items may contain or come into contact with common allergens such as milk, eggs, wheat, soy, nuts, or shellfish.
</p>
<div class="hero-ctas" style="margin-top: 2rem; justify-content: center;">
<a class="btn btn-primary" href="https://www.facebook.com/profile.php?id=100086635646712" target="_blank" rel="noopener">See Daily Specials on Facebook</a>
<a class="btn btn-outline" href="mailto:info@theoldfitzpub.com">Ask a Kitchen Question</a>
</div>
</div>
</div>
</section>
</main>
<footer>
<div class="container">
<div class="footer-grid">
<div>
<a class="footer-brand" href="./">
<img class="knot" src="./assets/knot.png" alt="">
<span>THE OLD FITZ PUBLIC HOUSE</span>
</a>
<address>
100 N Washington Street<br>
Marion, Indiana
</address>
</div>
<div>
<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>
<p class="col-title">Get in Touch</p>
<a class="email" href="mailto:info@theoldfitzpub.com">info@theoldfitzpub.com</a>
<div class="social-row">
<a href="https://www.instagram.com/theoldfitzpub/?hl=en" target="_blank" rel="noopener" aria-label="Instagram">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<rect x="3" y="3" width="18" height="18" rx="5" />
<circle cx="12" cy="12" r="4.2" />
<circle cx="17.2" cy="6.8" r="1" />
</svg>
</a>
<a href="https://www.facebook.com/profile.php?id=100086635646712" target="_blank" rel="noopener" aria-label="Facebook">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<path d="M15 4h-2.2C10.7 4 9.5 5.3 9.5 7.3V10H7v3.2h2.5V21h3.3v-7.8h2.6l.4-3.2h-3V7.6c0-.8.4-1.3 1.4-1.3H15V4Z" />
</svg>
</a>
</div>
</div>
</div>
<div class="footer-bottom">
<span>© 2026 The Old Fitz Public House</span>
<span>A neighborhood pub in Marion, Indiana</span>
</div>
</div>
</footer>
<script src="main.js<?= asset_version('main.js') ?>" defer></script>
</body>
</html>
+2430
View File
File diff suppressed because it is too large. Load diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "theoldfitz",
"version": "1.0.0",
"description": "The Old Fitz Public House website",
"main": "main.js",
"scripts": {
"dev": "concurrently --kill-others-on-fail \"npm:dev:php\" \"npm:dev:sass\" \"npm:dev:browser\"",
"dev:php": "php -S localhost:8000",
"dev:sass": "sass --watch --source-map src/scss/styles.scss:styles.css",
"dev:browser": "browser-sync start --proxy http://localhost:8000 --files \"*.php,admin/**/*.php,inc/**/*.php,main.js,styles.css\" --no-open --no-ui",
"build:sass": "sass --no-source-map src/scss/styles.scss:styles.css",
"dist": "npm run build:sass && node scripts/build-dist.js"
},
"repository": {
"type": "git",
"url": "https://gitea.home.daviddyer.me/david/the-old-fitz.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"devDependencies": {
"browser-sync": "^3.0.4",
"concurrently": "^10.0.5",
"sass": "^1.103.1"
}
}
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env node
'use strict';
/**
* Assembles a clean, production-only copy of the site into dist/.
*
* This is an allowlist by design: only the files and folders listed below
* are copied. Dev-only stuff (node_modules/, src/, package*.json, .git/,
* .claude/, docker-compose.yaml, dev.sh, .hintrc, config.example.php, the
* live db/fitz.sqlite, ...) never ends up in dist/ — including anything
* added later, since it has to be added here explicitly first.
*
* Run with: npm run dist
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const DIST = path.join(ROOT, 'dist');
const FILES = [
'index.php',
'menu.php',
'main.js',
'styles.css',
'robots.txt',
'config.php', // holds the live FB_PAGE_ACCESS_TOKEN — see README before sharing dist/ anywhere but the server
];
const DIRS = ['admin', 'assets', 'bin', 'inc'];
function copyDir(src, dest) {
fs.cpSync(src, dest, {
recursive: true,
filter: (source) => path.basename(source) !== '.DS_Store',
});
}
// Fresh start every run so a file removed from the source tree doesn't
// linger in dist/ from a previous build.
fs.rmSync(DIST, { recursive: true, force: true });
fs.mkdirSync(DIST, { recursive: true });
const missing = [];
for (const file of FILES) {
const src = path.join(ROOT, file);
if (!fs.existsSync(src)) {
missing.push(file);
continue;
}
fs.copyFileSync(src, path.join(DIST, file));
}
for (const dir of DIRS) {
const src = path.join(ROOT, dir);
if (!fs.existsSync(src)) {
missing.push(dir + '/');
continue;
}
copyDir(src, path.join(DIST, dir));
}
// db/ ships with just .htaccess (denies direct web access to the sqlite
// file) — never the live fitz.sqlite itself. inc/db.php creates the
// schema on first run if the file isn't there, so shipping the dev
// database would just overwrite whatever is already live on the server.
const dbHtaccess = path.join(ROOT, 'db', '.htaccess');
if (fs.existsSync(dbHtaccess)) {
fs.mkdirSync(path.join(DIST, 'db'), { recursive: true });
fs.copyFileSync(dbHtaccess, path.join(DIST, 'db', '.htaccess'));
} else {
missing.push('db/.htaccess');
}
if (missing.length) {
console.warn('Warning: expected but not found, skipped:\n ' + missing.join('\n '));
}
console.log(`Deploy folder built at ${path.relative(ROOT, DIST)}/`);
+21
View File
@@ -0,0 +1,21 @@
:root {
--ground: #171310;
--ground-2: #0f0c0a;
--surface: #221c16;
--surface-2: #2b2319;
--gold: #c9a35c;
--gold-bright: #e6c789;
--gold-dim: #a68b59;
--green: #33503f;
--green-deep: #203026;
--ink: #f1e8d8;
--ink-muted: #a89a83;
--rule: #42362590;
--font-serif: 'Fraunces', Georgia, 'Times New Roman', serif;
--font-mono: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
--font-sans: 'Karla', ui-sans-serif, system-ui, sans-serif;
--danger-bg: rgba(180, 50, 40, 0.15);
--danger-text: #ff9d9d;
}
+57
View File
@@ -0,0 +1,57 @@
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
body {
margin: 0;
background: var(--ground);
color: var(--ink);
font-family: var(--font-sans);
font-size: 1rem;
line-height: 1.65;
-webkit-font-smoothing: antialiased;
}
a {
color: inherit;
}
img,
svg {
display: block;
max-width: 100%;
}
h1,
h2,
h3 {
font-family: var(--font-serif);
font-weight: 600;
line-height: 1.08;
text-wrap: balance;
margin: 0;
}
p {
margin: 0;
}
:focus-visible {
outline: 2px solid var(--gold-bright);
outline-offset: 3px;
}
/* ---------- sections ---------- */
section {
padding-block: 5.5rem;
}
+80
View File
@@ -0,0 +1,80 @@
/* ---------- wrap-aware separators (JS-toggled, see main.js) ----------
Shared hook used by the announcement banner and hero tagline row: a
"·" between two pieces of text that can independently wrap onto
separate lines. No CSS selector exists for "am I on the same line as
my sibling," so main.js measures and toggles this class instead of a
fixed breakpoint. Visible by default so a no-JS visitor always sees
the dot (today's behavior), never hidden by default.
display, not visibility: a hidden separator must take no space, or
it still gets counted when justify-content: center balances its flex
line — a lone badge sharing its line with a space-reserving invisible
dot renders off-center. Safe from the "text merges together" bug that
originally motivated reserving space: both wrap-sep contexts space
their siblings with a real flex `gap` (see .announcement-inner,
.tagline-row) rather than the separator's own margin, so removing it
from flow still leaves normal spacing behind, never a collision. */
.wrap-sep-hidden {
display: none;
}
.container {
width: 100%;
max-width: 70rem;
margin: 0 auto;
padding-inline: 1.5rem;
}
.eyebrow {
font-family: var(--font-mono);
font-size: 0.72rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--gold);
}
.muted {
color: var(--ink-muted);
}
.skip-link {
position: absolute;
left: 1rem;
top: -3rem;
background: var(--gold);
color: var(--ground-2);
padding: 0.6rem 1rem;
border-radius: 0.125rem;
font-family: var(--font-mono);
font-size: 0.85rem;
transition: top .15s ease;
z-index: 100;
}
.skip-link:focus {
top: 1rem;
}
/* scroll reveal */
.reveal {
opacity: 0;
transform: translateY(0.875rem);
transition: opacity .6s ease, transform .6s ease;
}
.reveal.is-visible {
opacity: 1;
transform: none;
}
@media (prefers-reduced-motion: reduce) {
.reveal {
opacity: 1;
transform: none;
transition: none;
}
}
.font-mono {
font-family: var(--font-mono);
}
+43
View File
@@ -0,0 +1,43 @@
/* 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;
flex-wrap: nowrap;
white-space: nowrap;
}
.row-actions .btn {
white-space: nowrap;
}
/* 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;
}
@media (max-width: 47.5em) {
.row-actions {
flex-wrap: wrap;
white-space: normal;
}
}
.empty-state {
text-align: center;
padding: 3rem 1.5rem;
color: var(--ink-muted);
}
.empty-state p {
font-size: 0.95rem;
margin-bottom: 1.25rem;
}
+288
View File
@@ -0,0 +1,288 @@
/* 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. */
/* 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: auto 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-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-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
}
.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);
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--gold);
font-weight: 500;
}
.item-row,
.ann-row,
.user-row {
padding: 0.85rem 0.75rem;
border-bottom: 1px solid var(--rule);
}
.item-row:last-child,
.ann-row:last-child,
.user-row:last-child {
border-bottom: none;
}
/* 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: "status username role created actions";
}
.col-status,
.item-row [data-label="Status"],
.ann-row [data-label="Status"],
.user-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; }
.item-row [data-label="Item"] { grid-area: item; }
.item-row [data-label="Price"] { grid-area: price; }
.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;
}
.timestamp-cell {
color: var(--ink-muted);
font-size: 0.8rem;
}
/* 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) {
/* 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). */
/* Every list's cards get a second column on mobile so Actions can sit
in the top-right corner alongside that list's leading field, instead
of stacking at the very bottom of the card — overrides the shared
single-column rule that would otherwise apply here too.
minmax(0, auto), not plain auto, so this column can still shrink
below its content's natural width under pressure instead of forcing
the whole row to overflow the card. */
.item-grid-wrap,
.ann-grid-wrap,
.user-grid-wrap {
grid-template-columns: 1fr minmax(0, auto);
}
/* Users' actions column gets a fixed cap instead of auto: "Change
Password" is long enough that its max-content size (what auto would
size the column to) is wider than this corner has room for, which
pushed the button past the card edge. A firm cap plus the
wrap/stretch rules below keep it predictable instead. */
.user-grid-wrap {
grid-template-columns: minmax(0, 1fr) 6rem;
}
/* Every row's Actions cell holds one or two .action-stacks (move
up/down, edit/delete, etc. — each stacked vertically, see
.action-stack itself further up) side by side. Keeps those stacks
from wrapping onto their own lines in this narrow corner, which is
what the shared .row-actions wrap rule below would otherwise do. */
.item-row .row-actions,
.ann-row .row-actions,
.user-row .row-actions {
flex-wrap: nowrap;
}
.item-grid-head,
.ann-grid-head,
.user-grid-head {
display: none;
}
.item-row,
.ann-row,
.user-row {
row-gap: 0.4rem;
border: 1px solid var(--rule);
border-radius: 0.25rem;
padding: 0.9rem 1rem;
margin-bottom: 0.85rem;
}
.item-row:last-child,
.ann-row:last-child,
.user-row:last-child {
border-bottom: none;
margin-bottom: 0;
}
.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);
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--gold);
margin-bottom: 0.3rem;
}
.item-row {
grid-template-areas:
"status actions"
"item item"
"price price";
/* Overrides the shared align-items: center above — on desktop that
vertically centers Status against the taller Item cell in one wide
row, which is fine, but here it also centers Status against the
taller Actions block above, pushing its badge down instead of
keeping both flush with the card's top edge. */
align-items: start;
}
.ann-row {
grid-template-areas:
"status actions"
"headline headline"
"type type"
"schedule schedule"
"placements placements";
align-items: start;
}
.user-row {
grid-template-areas:
"status actions"
"username username"
"role role"
"created created";
align-items: start;
}
/* Stretches Actions' whole flex chain out to the fixed 6rem column
above (each is shrink-wrapped by default), then lets "Change
Password" — long enough on its own to blow out this corner next to a
username — wrap onto two lines within that width instead of forcing
the column wider. Every other action-stack button here (Edit,
Delete, ↑/↓) is short enough to stay on one line regardless. */
.user-row .row-actions,
.user-row .action-stack,
.user-row .row-actions .btn {
width: 100%;
}
.user-row .btn-change-password {
white-space: normal;
}
}
+76
View File
@@ -0,0 +1,76 @@
/* ---------- admin modal dialogs ---------- */
.admin-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 12, 10, 0.8);
backdrop-filter: blur(0.25rem);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1.5rem;
opacity: 0;
pointer-events: none;
// Closed modals must be visibility: hidden, not just transparent —
// otherwise their fields stay focusable and Safari offers saved logins
// for an invisible Username field. The visibility switch waits for the
// fade-out to finish so the close animation still plays.
visibility: hidden;
transition: opacity .2s ease, visibility 0s linear .2s;
}
.admin-modal-backdrop.is-open {
opacity: 1;
pointer-events: auto;
visibility: visible;
transition: opacity .2s ease, visibility 0s;
}
.admin-modal {
background: var(--surface);
border: 1px solid var(--rule);
border-radius: 0.25rem;
width: 100%;
max-width: 36rem;
max-height: 90vh;
overflow-y: auto;
padding: 2rem;
box-shadow: 0 1.5rem 3rem rgba(0, 0, 0, 0.6);
transform: translateY(0.75rem);
transition: transform .2s ease;
}
.admin-modal-backdrop.is-open .admin-modal {
transform: translateY(0);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--rule);
}
.modal-header h2 {
font-size: 1.35rem;
color: var(--gold-bright);
margin: 0;
}
.modal-close {
background: transparent;
border: none;
color: var(--ink-muted);
font-size: 1.5rem;
cursor: pointer;
padding: 0.25rem;
line-height: 1;
text-decoration: none;
transition: color .15s ease;
}
.modal-close:hover {
color: var(--gold-bright);
}
@@ -0,0 +1,71 @@
/* ---------- announcement banner ---------- */
.announcement-banner {
background: var(--ground-2);
border-bottom: 1px solid var(--rule);
padding: 0.75rem 0;
font-size: 0.88rem;
position: relative;
z-index: 40;
}
.announcement-inner {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
flex-wrap: wrap;
text-align: center;
}
.announcement-tag {
font-family: var(--font-mono);
font-size: 0.75rem;
letter-spacing: 0.12em;
text-transform: uppercase;
font-weight: 600;
padding: 0.2rem 0.55rem;
border-radius: 0.125rem;
}
.announcement-sep {
color: var(--gold-dim);
user-select: none;
}
.announcement-text {
color: var(--ink);
font-weight: 400;
}
/* banner theme styles */
.banner-holiday {
background: linear-gradient(90deg, rgba(32, 48, 38, 0.9), rgba(15, 12, 10, 0.95), rgba(32, 48, 38, 0.9));
border-bottom-color: var(--rule);
}
.banner-holiday .announcement-tag {
background: rgba(201, 163, 92, 0.18);
border: 1px solid var(--gold-dim);
color: var(--gold-bright);
}
.banner-special {
background: linear-gradient(90deg, rgba(43, 35, 25, 0.9), rgba(15, 12, 10, 0.95), rgba(43, 35, 25, 0.9));
border-bottom-color: var(--gold-dim);
}
.banner-special .announcement-tag {
background: var(--gold);
color: var(--ground-2);
}
.banner-closure {
background: linear-gradient(90deg, rgba(80, 24, 20, 0.85), rgba(15, 12, 10, 0.95), rgba(80, 24, 20, 0.85));
border-bottom-color: rgba(200, 60, 50, 0.4);
}
.banner-closure .announcement-tag {
background: rgba(220, 60, 50, 0.25);
border: 1px solid rgba(220, 80, 70, 0.6);
color: #ffb4b4;
}
+90
View File
@@ -0,0 +1,90 @@
.hero-ctas {
margin-top: 2.5rem;
display: flex;
justify-content: center;
gap: 0.9rem;
flex-wrap: wrap;
}
.btn {
display: inline-flex;
align-items: center;
/* Centers label text horizontally too — matters once a button is
stretched wider than its content (e.g. action-stack children filling
a fixed-width column) or wraps to multiple lines; a no-op otherwise,
so every button gets this instead of one-off overrides per case. */
justify-content: center;
gap: 0.5rem;
padding: 0.8rem 1.5rem;
font-family: var(--font-mono);
font-size: 0.8rem;
letter-spacing: 0.05em;
text-decoration: none;
border-radius: 0.125rem;
transition: transform .15s ease, background .15s ease, border-color .15s ease;
}
.btn:hover {
transform: translateY(-1px);
}
.btn:disabled {
opacity: 0.35;
cursor: not-allowed;
transform: none;
}
/* :where() keeps this native-button reset at zero specificity, so
modifier classes like .btn-primary's background can actually win
instead of losing to button.btn's higher (0,1,1) specificity */
:where(button.btn) {
cursor: pointer;
background: transparent;
}
.btn-primary {
background: linear-gradient(180deg, var(--gold-bright), var(--gold));
color: var(--ground-2);
font-weight: 500;
border: none;
}
.btn-primary:hover {
background: linear-gradient(180deg, var(--gold-bright), var(--gold-bright));
}
.btn-outline {
border: 1px solid var(--gold-dim);
color: var(--gold-bright);
}
/* .nav-cta mirrors this hover treatment */
.btn-outline:hover,
.nav-cta:hover {
border-color: var(--gold);
background: var(--surface);
}
.btn-sm {
padding: 0.4rem 0.8rem;
font-size: 0.75rem;
line-height: 1.2;
}
/* Single-glyph reorder buttons (↑ / ↓) — narrower than .btn-sm's text
padding since there's no label to give it room to breathe. */
.btn-icon {
padding-inline: 0.55rem;
}
.btn-danger {
background: var(--danger-bg);
border: 1px solid rgba(220, 80, 60, 0.5);
color: var(--danger-text);
}
.btn-danger:hover {
background: rgba(220, 60, 50, 0.3);
border-color: #ff6e6e;
color: #fff;
}
+89
View File
@@ -0,0 +1,89 @@
/* shared surface-panel look: also used by .card, .login-card, .panel below */
.stat-card,
.card,
.login-card,
.panel,
.menu-category {
background: var(--surface);
border: 1px solid var(--rule);
}
.stat-card {
margin-top: 2rem;
padding: 2rem;
border-radius: 0.2rem;
}
.stat-card .eyebrow {
display: block;
margin-bottom: 1.1rem;
}
.stat-big {
font-family: var(--font-serif);
font-weight: 600;
font-style: italic;
font-size: 3rem;
color: var(--gold-bright);
line-height: 1;
}
.stat-card .stat-label {
margin-top: 0.4rem;
font-size: 0.92rem;
color: var(--ink-muted);
}
.stat-source {
margin-top: 1.5rem;
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--gold-dim);
letter-spacing: 0.05em;
}
.card {
border-radius: 0.2rem;
padding: 2.25rem;
}
.card h3 {
font-size: 1.3rem;
color: var(--gold-bright);
margin-bottom: 1.4rem;
}
.card .note {
margin-top: 1.25rem;
font-size: 0.88rem;
color: var(--ink-muted);
}
.card .note strong {
color: var(--ink);
font-weight: 600;
}
.panel {
border-radius: 0.25rem;
padding: 2rem;
margin-bottom: 2rem;
}
.panel h2 {
font-size: 1.35rem;
color: var(--gold-bright);
margin-bottom: 1.25rem;
display: flex;
align-items: center;
gap: 0.6rem;
}
.panel-head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
+34
View File
@@ -0,0 +1,34 @@
/* ---------- knot mark ---------- */
.knot {
display: block;
width: 1.75rem;
height: 1.75rem;
object-fit: contain;
flex-shrink: 0;
}
.rule-row .knot {
width: 1.375rem;
height: 1.375rem;
opacity: 0.9;
}
.footer-brand .knot {
width: 1.625rem;
height: 1.625rem;
}
/* ---------- brass rule divider ---------- */
.rule-row {
display: flex;
align-items: center;
gap: 1rem;
max-width: 13.75rem;
margin-inline: auto;
}
.rule-row .line {
height: 1px;
flex: 1;
background: linear-gradient(90deg, transparent, var(--gold-dim), transparent);
}
+153
View File
@@ -0,0 +1,153 @@
.form-group {
margin-bottom: 1.25rem;
}
.form-group.full-width {
grid-column: 1 / -1;
}
.form-group label {
display: block;
font-family: var(--font-mono);
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--gold);
margin-bottom: 0.45rem;
}
.field-hint {
font-size: 0.78rem;
}
.form-control,
.form-input {
width: 100%;
background: var(--ground-2);
border: 1px solid var(--rule);
color: var(--ink);
font-family: var(--font-sans);
font-size: 0.95rem;
padding: 0.7rem 0.85rem;
border-radius: 0.125rem;
box-sizing: border-box;
outline: none;
transition: border-color .15s ease;
}
.form-control:focus,
.form-input:focus {
border-color: var(--gold-bright);
}
textarea.form-control {
resize: vertical;
min-height: 5.5rem;
line-height: 1.5;
}
.error-msg,
.alert-error {
background: var(--danger-bg);
border: 1px solid rgba(220, 80, 60, 0.4);
color: var(--danger-text);
padding: 0.85rem 1.1rem;
font-size: 0.88rem;
border-radius: 0.125rem;
margin-bottom: 1.25rem;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
@media (max-width: 47.5em) {
.form-grid {
grid-template-columns: 1fr;
}
}
.form-actions {
display: flex;
gap: 0.75rem;
align-items: center;
margin-top: 1.75rem;
}
.checkbox-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 0.5rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 0.92rem;
color: var(--ink);
cursor: pointer;
}
.checkbox-label-lg {
font-size: 1.05rem;
font-weight: 500;
}
.checkbox-label input[type="checkbox"] {
width: 1.15rem;
height: 1.15rem;
accent-color: var(--gold);
cursor: pointer;
}
/* password toggle input wrapper */
.password-input-wrap {
position: relative;
display: flex;
align-items: center;
}
.password-input-wrap .form-control,
.password-input-wrap .form-input {
padding-right: 2.75rem;
}
.password-toggle-btn {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
background: transparent;
border: none;
color: var(--ink-muted);
cursor: pointer;
padding: 0.35rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.125rem;
transition: color .15s ease;
}
.password-toggle-btn:hover {
color: var(--gold-bright);
}
.password-toggle-btn svg {
width: 1.15rem;
height: 1.15rem;
}
.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;
}
+32
View File
@@ -0,0 +1,32 @@
.social-row {
margin-top: 1.5rem;
display: flex;
gap: 0.9rem;
}
.social-row a {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.375rem;
height: 2.375rem;
border: 1px solid var(--rule);
border-radius: 0.125rem;
color: var(--ink-muted);
text-decoration: none;
transition: border-color .15s ease, color .15s ease;
}
.social-row a:hover {
border-color: var(--gold);
color: var(--gold-bright);
}
.social-row svg {
width: 1.0625rem;
height: 1.0625rem;
}
footer .social-row {
margin-top: 1rem;
}
+69
View File
@@ -0,0 +1,69 @@
.status-badge {
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 0.4rem;
font-family: var(--font-mono);
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 0.25rem 0.6rem;
border-radius: 0.125rem;
white-space: nowrap;
line-height: 1.2;
}
.status-badge.active {
background: rgba(51, 80, 63, 0.4);
border: 1px solid var(--green);
color: #9fe2bf;
}
.status-badge.inactive {
background: rgba(138, 115, 74, 0.2);
border: 1px solid var(--rule);
color: var(--ink-muted);
}
/* 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 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;
transition: opacity .15s ease;
}
button.status-badge:hover {
opacity: 0.8;
}
button.status-badge:disabled {
cursor: not-allowed;
opacity: 0.5;
}
/* "● Available" is the longest menu item label, ~6.7rem measured. */
.item-row button.status-badge {
width: 7rem;
}
/* "⏳ Scheduled" is the longest announcement label, ~7rem measured. */
.ann-row button.status-badge {
width: 7.25rem;
}
/* "○ Disabled" is the longer of the two user labels. Not scoped to
button.status-badge like the two above — your own row's badge is a
plain non-interactive span (see users.php), and it still needs to line
up in the same column. */
.user-row .status-badge {
width: 7rem;
}
+106
View File
@@ -0,0 +1,106 @@
.admin-nav {
background: var(--ground-2);
border-bottom: 1px solid var(--rule);
padding: 0.85rem 0;
}
.admin-nav-inner {
display: flex;
justify-content: space-between;
align-items: center;
}
.admin-brand {
display: flex;
align-items: center;
gap: 0.75rem;
text-decoration: none;
font-family: var(--font-serif);
font-size: 1.15rem;
color: var(--ink);
}
.admin-user {
display: flex;
align-items: center;
gap: 1.25rem;
font-family: var(--font-mono);
font-size: 0.82rem;
color: var(--ink-muted);
}
.admin-user a {
color: var(--gold-bright);
text-decoration: none;
}
.admin-user a:hover {
text-decoration: underline;
}
@media (max-width: 47.5em) {
.admin-nav-inner {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
/* two classes each, so these reliably outrank .admin-nav-links's and
.admin-user's own base-rule gap regardless of source order */
.admin-nav-inner .admin-nav-links,
.admin-nav-inner .admin-user {
flex-wrap: wrap;
gap: 0.5rem 1rem;
}
}
.dashboard-layout {
padding-block: 2.5rem 4rem;
}
.dash-header {
margin-bottom: 2rem;
}
.dash-header h1 {
margin-top: 0.4rem;
font-size: 2rem;
color: var(--ink);
}
/* admin nav links */
.admin-nav-links {
display: flex;
gap: 1.25rem;
align-items: center;
}
.admin-nav-links a {
font-family: var(--font-mono);
font-size: 0.82rem;
letter-spacing: 0.05em;
text-decoration: none;
color: var(--ink-muted);
padding: 0.35rem 0.65rem;
border-radius: 0.125rem;
transition: color .15s ease, background .15s ease;
}
.admin-nav-links a:hover {
color: var(--gold-bright);
background: var(--surface);
}
.admin-nav-links a.active {
color: var(--gold-bright);
background: var(--surface);
border-bottom: 1px solid var(--gold);
}
.admin-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
margin-bottom: 2rem;
}
+82
View File
@@ -0,0 +1,82 @@
/* footer */
footer {
background: var(--ground-2);
border-top: 1px solid var(--rule);
padding-block: 3.5rem 2.5rem;
}
.footer-grid {
display: grid;
grid-template-columns: 1.2fr 1fr 1fr;
gap: 2.5rem;
}
.footer-brand {
display: flex;
align-items: center;
gap: 0.6rem;
text-decoration: none;
}
.footer-brand span {
font-family: var(--font-serif);
font-weight: 600;
color: var(--ink);
font-size: 1rem;
}
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;
}
footer address {
margin-top: 1rem;
font-style: normal;
color: var(--ink-muted);
font-size: 0.92rem;
line-height: 1.7;
}
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;
}
footer a.email {
color: var(--gold-bright);
text-decoration: none;
font-size: 0.92rem;
}
footer a.email:hover {
text-decoration: underline;
}
.footer-bottom {
margin-top: 3rem;
padding-top: 1.75rem;
border-top: 1px solid var(--rule);
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.75rem;
font-size: 0.78rem;
color: var(--ink-muted);
}
@media (max-width: 45em) {
.footer-grid {
grid-template-columns: 1fr;
gap: 2rem;
}
}
+128
View File
@@ -0,0 +1,128 @@
/* ---------- header / nav ---------- */
header.site {
position: sticky;
top: 0;
z-index: 50;
background: rgba(15, 12, 10, 0.86);
backdrop-filter: saturate(140%) blur(0.5rem);
border-bottom: 1px solid var(--rule);
}
.nav-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding-block: 0.75rem;
}
.brand {
display: flex;
align-items: center;
gap: 0.65rem;
text-decoration: none;
font-family: var(--font-serif);
font-weight: 600;
letter-spacing: 0.02em;
font-size: 1.05rem;
color: var(--ink);
}
nav.links {
display: flex;
align-items: center;
gap: 1.75rem;
}
nav.links a {
text-decoration: none;
font-size: 0.92rem;
letter-spacing: 0.03em;
color: var(--ink-muted);
padding: 0.4rem 0.2rem;
transition: color .15s ease;
}
nav.links a:hover {
color: var(--gold-bright);
}
nav.links a.active {
color: var(--gold-bright);
}
.nav-cta {
display: inline-flex;
align-items: center;
border: 1px solid var(--gold-dim);
color: var(--gold-bright) !important;
padding: 0.45rem 0.9rem;
border-radius: 0.125rem;
font-family: var(--font-mono);
font-size: 0.75rem !important;
letter-spacing: 0.06em;
}
/* mobile nav toggle: hidden on desktop, shown only under the nav.links
breakpoint below. Icon swap is driven by the button's own
aria-expanded state rather than a second JS-toggled class. */
.nav-toggle {
display: none;
background: transparent;
border: none;
padding: 0.5rem;
margin: -0.5rem;
color: var(--ink);
cursor: pointer;
align-items: center;
justify-content: center;
}
.nav-toggle svg {
width: 1.5rem;
height: 1.5rem;
}
.nav-toggle .icon-close {
display: none;
}
.nav-toggle[aria-expanded="true"] .icon-menu {
display: none;
}
.nav-toggle[aria-expanded="true"] .icon-close {
display: block;
}
@media (max-width: 47.5em) {
.nav-toggle {
display: inline-flex;
}
.nav-cta {
display: none;
}
nav.links {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
flex-direction: column;
align-items: stretch;
gap: 0;
background: var(--ground-2);
border-bottom: 1px solid var(--rule);
padding: 0.5rem 0;
}
nav.links.is-open {
display: flex;
}
nav.links a {
padding: 0.9rem 1.5rem;
}
}
+7
View File
@@ -0,0 +1,7 @@
/* ---------- admin: my account ---------- */
/* One panel, fields in the shared two-column .form-grid (which already
stacks below 47.5em). Capped so the fields don't stretch across the
full dashboard width on large screens. */
.account-panel {
max-width: 56rem;
}
+143
View File
@@ -0,0 +1,143 @@
/* ---------- 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;
}
}
+61
View File
@@ -0,0 +1,61 @@
/* ---------- admin / management styles ---------- */
.admin-login-body {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.login-card {
border-radius: 0.25rem;
padding: 2.5rem 2rem;
width: 100%;
max-width: 25rem;
box-shadow: 0 1rem 2.5rem rgba(0, 0, 0, 0.4);
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-header img {
width: 2rem;
height: 2rem;
margin: 0 auto 0.75rem;
}
.login-header h1 {
font-size: 1.5rem;
color: var(--gold-bright);
}
.login-header p {
font-family: var(--font-mono);
font-size: 0.72rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--ink-muted);
margin-top: 0.35rem;
}
.login-btn {
width: 100%;
justify-content: center;
margin-top: 0.5rem;
}
.back-link {
display: block;
text-align: center;
margin-top: 1.5rem;
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--ink-muted);
text-decoration: none;
}
.back-link:hover {
color: var(--gold-bright);
}
+66
View File
@@ -0,0 +1,66 @@
/* two classes to reliably outrank .panel h2's margin-bottom above */
.category-panel .cat-name-heading {
margin-bottom: 0.25rem;
grid-area: name;
}
.cat-desc-text {
font-size: 0.88rem;
}
.category-panel .cat-desc-text {
grid-area: desc;
}
/* Category panel-head as a grid: title on its own top row, description
and the move/edit/delete buttons sharing the row below with the
buttons pinned to the far end (matches the desktop arrangement this
replaced — see the mobile override for how that changes below 760px). */
.category-panel .panel-head {
display: grid;
grid-template-columns: 1fr auto;
grid-template-areas:
"name actions"
"desc actions";
align-items: center;
column-gap: 0.75rem;
}
.category-panel .panel-head .row-actions {
grid-area: actions;
}
/* Wraps the item name and its optional badge (reusing the public menu's
.menu-item-badge pill) so they sit on one inline-flowing line instead
of giving the badge its own column. */
.item-name-row {
display: inline-flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.item-desc-cell {
font-size: 0.82rem;
margin-top: 0.2rem;
}
.item-price-cell {
color: var(--gold-bright);
font-weight: 500;
}
/* mobile reflow for the category header — shares the breakpoint and
reflow approach used by the item/announcement/user list grids in
admin-data-grid, but this one rule is specific to the menu editor's
category panel, so it lives here instead of there. */
@media (max-width: 47.5em) {
.category-panel .panel-head {
grid-template-columns: 1fr;
grid-template-areas:
"name"
"desc"
"actions";
row-gap: 0.5rem;
}
}
+392
View File
@@ -0,0 +1,392 @@
.hero-brand {
margin: 0;
line-height: 0;
}
.hero-logo {
display: block;
width: clamp(17.5rem, 28vw, 31rem);
max-width: 100%;
height: auto;
margin: 0 auto 1.75rem;
}
/* hours card alert callout */
.hours-alert {
margin-bottom: 1.25rem;
padding: 0.85rem 1rem;
border-radius: 0.2rem;
font-size: 0.88rem;
line-height: 1.45;
}
.hours-alert strong {
display: block;
font-family: var(--font-mono);
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 0.25rem;
}
.hours-alert.alert-holiday {
background: rgba(51, 80, 63, 0.25);
border: 1px solid rgba(201, 163, 92, 0.3);
color: var(--ink);
}
.hours-alert.alert-holiday strong {
color: var(--gold-bright);
}
.hours-alert.alert-special {
background: rgba(43, 35, 25, 0.6);
border: 1px solid var(--gold-dim);
color: var(--ink);
}
.hours-alert.alert-special strong {
color: var(--gold-bright);
}
.hours-alert.alert-closure {
background: rgba(80, 24, 20, 0.3);
border: 1px solid rgba(220, 80, 70, 0.5);
color: #ffdfdf;
}
.hours-alert.alert-closure strong {
color: #ffb4b4;
}
/* ---------- hero ---------- */
.hero {
position: relative;
min-height: calc(100vh - 4.5rem);
display: flex;
align-items: center;
justify-content: center;
padding-block: clamp(4.5rem, 8vh, 7.5rem);
text-align: center;
overflow: hidden;
background: var(--ground);
/* fallback if brick-texture.png fails to load */
}
.hero::before {
content: "";
position: absolute;
inset: 0;
z-index: 1;
opacity: 0.6;
background:
linear-gradient(to bottom, rgba(0, 0, 0, 0.12), transparent 22%, transparent 78%, rgba(0, 0, 0, 0.18));
pointer-events: none;
}
.hero-media {
position: absolute;
inset: 0;
z-index: 0;
overflow: hidden;
background-image: url('./assets/brick-texture.png');
background-repeat: repeat;
}
.hero-scrim {
position: absolute;
inset: 0;
background:
linear-gradient(180deg, rgba(15, 12, 10, 0.35) 0%, rgba(15, 12, 10, 0.2) 45%, rgba(15, 12, 10, 0.6) 100%),
radial-gradient(ellipse 60% 48% at 50% 18%, rgba(201, 163, 92, 0.16), transparent 58%);
}
.hero-inner {
position: relative;
z-index: 2;
}
.hero .eyebrow {
margin-bottom: 1rem;
}
.hero h1 {
font-size: clamp(3.1rem, 9vw, 6.2rem);
color: var(--gold-bright);
letter-spacing: -0.01em;
}
.hero .sub {
font-family: var(--font-serif);
font-style: italic;
font-weight: 400;
font-size: clamp(1.15rem, 2.6vw, 1.55rem);
color: var(--ink-muted);
margin-top: 0.35rem;
}
.tagline-row {
margin-top: 2rem;
display: flex;
justify-content: center;
flex-wrap: wrap;
/* column-gap carries the spacing now (was 0 — the dot's own margin
used to be the only thing separating the phrases). Needs to be a
real flex gap, not a margin on the dot, so removing a hidden dot
from flow (see .wrap-sep-hidden) still leaves normal spacing
between the two phrases instead of running them together. */
gap: 0.6rem 0.7rem;
font-family: var(--font-mono);
font-size: 0.82rem;
letter-spacing: 0.04em;
color: var(--ink-muted);
}
/* Real element instead of a ::after pseudo-element — needs to be a
measurable, independently-toggleable DOM node for the wrap-aware
script in main.js to hide it when its neighbors wrap apart. No margin
of its own: the column-gap above already provides 0.7rem on each
side of it (matching the old margin-inline's total spacing exactly),
and still applies directly between the two phrases whenever the dot
itself is removed from flow. */
.tag-sep {
color: var(--gold-dim);
user-select: none;
}
.section-head {
margin-bottom: 2.75rem;
}
.section-head h2 {
font-size: clamp(1.9rem, 3.6vw, 2.6rem);
color: var(--ink);
margin-top: 0.5rem;
}
/* about band */
.about-grid {
display: grid;
grid-template-columns: 1.3fr 1fr;
gap: 3.5rem;
align-items: start;
}
.about-grid p+p {
margin-top: 1rem;
}
.about-grid h2 {
margin-top: 0.6rem;
}
.about-grid h2 + .lede {
margin-top: 1.5rem;
}
.about-grid .lede {
max-width: 56ch;
color: var(--ink);
font-size: 1.05rem;
}
@media (max-width: 51.25em) {
.about-grid {
grid-template-columns: 1fr;
}
}
/* hours / visit band */
.visit {
background: var(--ground-2);
}
.visit-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.75rem;
}
.hours-list {
margin: 0;
padding: 0;
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-size: 0.92rem;
}
.hours-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.55rem 0;
border-bottom: 1px solid var(--rule);
}
.hours-row:last-child {
border-bottom: none;
}
.hours-row dt {
margin: 0;
color: var(--ink-muted);
letter-spacing: 0.04em;
font-weight: normal;
}
.hours-row dd {
margin: 0;
text-align: right;
color: var(--ink);
}
.find-us .pin {
width: 2.125rem;
height: 2.125rem;
margin-bottom: 1rem;
}
.find-us address {
font-style: normal;
font-size: 1rem;
color: var(--ink);
}
.find-us .contact-list {
margin-top: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.find-us .contact-list a {
text-decoration: none;
color: var(--gold-bright);
font-size: 0.92rem;
display: inline-flex;
align-items: center;
gap: 0.5rem;
width: fit-content;
border-bottom: 1px solid transparent;
}
.find-us .contact-list a:hover {
border-bottom-color: var(--gold-dim);
}
@media (max-width: 45em) {
.visit-grid {
grid-template-columns: 1fr;
}
}
/* live music band */
.music {
background: linear-gradient(180deg, var(--green-deep), var(--green));
color: var(--ink);
text-align: center;
}
.music-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3.5rem;
align-items: center;
text-align: left;
margin-top: 1.5rem;
}
.music-content {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.music-photo-wrap {
position: relative;
border-radius: 0.25rem;
overflow: hidden;
border: 1px solid rgba(201, 163, 92, 0.35);
box-shadow: 0 1rem 2.5rem rgba(0, 0, 0, 0.45);
}
.music-photo {
width: 100%;
height: auto;
display: block;
object-fit: cover;
aspect-ratio: 4 / 3;
object-position: center top;
}
.music .section-head {
margin-bottom: 1.5rem;
text-align: left;
}
.music .section-head .eyebrow {
color: var(--gold-bright);
}
.music p.body {
max-width: 58ch;
margin: 0;
font-size: 1.02rem;
color: #e4ddc9;
line-height: 1.65;
}
.music .btn-outline {
border-color: rgba(230, 199, 137, 0.45);
}
.music .hero-ctas {
margin-top: 2rem;
justify-content: flex-start;
}
@media (max-width: 50em) {
.music-grid {
grid-template-columns: 1fr;
gap: 2.5rem;
text-align: center;
}
.music-content {
align-items: center;
}
.music .section-head {
text-align: center;
}
.music .hero-ctas {
justify-content: center;
}
}
/* menu band */
.menu-band .container {
max-width: 60rem;
text-align: center;
}
.menu-band p.body {
font-size: 1.02rem;
color: var(--ink-muted);
max-width: 56ch;
margin: 0 auto;
}
.menu-band .hero-ctas {
margin-top: 2.25rem;
justify-content: center;
}
.menu-band .section-head {
text-align: center;
}
.menu-band .rule-row {
margin-bottom: 1.75rem;
}
+98
View File
@@ -0,0 +1,98 @@
/* public online menu section */
.menu-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
gap: 2.5rem;
margin-top: 3rem;
text-align: left;
}
/* background/border come from the shared .panel rule above; no
margin-bottom here since these sit in a grid whose own gap spaces them */
.menu-category {
border-radius: 0.25rem;
padding: 2rem;
}
.menu-category-header {
margin-bottom: 1.5rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--rule);
}
.menu-category-header h3 {
font-size: 1.45rem;
color: var(--gold-bright);
}
.menu-category-header p {
margin-top: 0.35rem;
font-size: 0.88rem;
color: var(--ink-muted);
}
.menu-items-list {
display: flex;
flex-direction: column;
gap: 1.4rem;
}
.menu-item-row {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.menu-item-top {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.75rem;
}
.menu-item-name {
font-family: var(--font-serif);
font-weight: 600;
font-size: 1.05rem;
color: var(--ink);
display: inline-flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.menu-item-price {
font-family: var(--font-mono);
font-weight: 500;
font-size: 0.95rem;
color: var(--gold-bright);
white-space: nowrap;
}
.menu-item-desc {
font-size: 0.88rem;
color: var(--ink-muted);
line-height: 1.45;
}
.menu-item-badge {
font-family: var(--font-mono);
font-size: 0.65rem;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 0.15rem 0.45rem;
border-radius: 0.125rem;
background: rgba(201, 163, 92, 0.15);
border: 1px solid var(--gold-dim);
color: var(--gold-bright);
font-weight: normal;
}
/* menu page specific headers & spacing */
.menu-page-header {
padding-block: 4.5rem 2.5rem;
}
.menu-page-content {
padding-bottom: 5.5rem;
}
+34
View File
@@ -0,0 +1,34 @@
// The Old Fitz — stylesheet manifest. Forwards every partial below in
// cascade order; no rules are defined directly in this file.
// Abstracts
@use "abstracts/tokens";
// Base
@use "base/reset";
@use "base/utilities";
// Components
@use "components/dividers";
@use "components/buttons";
@use "components/cards";
@use "components/status-badge";
@use "components/admin-actions";
@use "components/admin-data-grid";
@use "components/admin-modal";
@use "components/forms";
@use "components/announcement-banner";
@use "components/social-row";
// Layout
@use "layout/header";
@use "layout/footer";
@use "layout/admin-shell";
// Pages
@use "pages/home";
@use "pages/menu";
@use "pages/admin-login";
@use "pages/admin-menu";
@use "pages/admin-hours";
@use "pages/admin-account";
+1992 -222
View File
File diff suppressed because it is too large. Load diff