Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51bdfafaed | ||
|
|
3a77bae21e | ||
|
|
69be043f9d | ||
|
|
a7fb530061 | ||
|
|
cf9fed749b | ||
|
|
7cac2ebcae | ||
|
|
e2c47bed61 | ||
|
|
759c22afaf | ||
|
|
bb65ecd43c | ||
|
|
c7b836bcf2 | ||
|
|
3286bce6b1 | ||
|
|
02551eebbc | ||
|
|
242c5165e1 | ||
|
|
91ec95a4a9 | ||
|
|
e0904b368c | ||
|
|
bbe84e8614 | ||
|
|
375407245c | ||
|
|
11c72d1231 | ||
|
|
3e5d804bc3 | ||
|
|
9a32d34662 | ||
|
|
5bed636cca | ||
|
|
2145094e55 | ||
|
|
cd4c6c1969 | ||
|
|
eab20899de | ||
|
|
fd928a24cf | ||
|
|
40f4c102b4 | ||
|
|
7c96b952e9 | ||
|
|
e68b24e1c7 | ||
|
|
803d02d758 | ||
|
|
50eeb41375 | ||
|
|
f1bbf59548 | ||
|
|
21ad6c31cf | ||
|
|
197047f4ef | ||
|
|
38b32f69fd | ||
|
|
bd22299247 | ||
|
|
e4cd4f6cfb | ||
|
|
b55c4f294f | ||
|
|
f851e8fd24 | ||
|
|
9b7d9ceccb | ||
|
|
b9d3262233 |
No files matched your search
@@ -0,0 +1,17 @@
|
|||||||
|
# Secrets and runtime state — never in the build context.
|
||||||
|
config.php
|
||||||
|
db/fitz.sqlite*
|
||||||
|
cache/
|
||||||
|
|
||||||
|
# Rebuilt inside the image.
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
styles.css
|
||||||
|
styles.css.map
|
||||||
|
|
||||||
|
# Dev-only.
|
||||||
|
.git/
|
||||||
|
.claude/
|
||||||
|
.DS_Store
|
||||||
|
.hintrc
|
||||||
|
dev.sh
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Build the site image and push it to this Gitea instance's container
|
||||||
|
# registry. Registry host and owner come from the instance itself, so the
|
||||||
|
# same file works on the home Gitea and the VPS Gitea without edits.
|
||||||
|
#
|
||||||
|
# Needs one repo secret: REGISTRY_TOKEN, a personal access token for the
|
||||||
|
# repo owner with package read/write scope.
|
||||||
|
|
||||||
|
name: Build image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
SERVER_URL: ${{ gitea.server_url }}
|
||||||
|
OWNER: ${{ gitea.repository_owner }}
|
||||||
|
REPO: ${{ gitea.repository }}
|
||||||
|
SHA: ${{ gitea.sha }}
|
||||||
|
steps:
|
||||||
|
# Trust the home CA when one is configured: instance variable HOME_CA
|
||||||
|
# holds the root cert as PEM. Leave it unset on hosts with a public
|
||||||
|
# cert (the VPS) and this step is skipped.
|
||||||
|
- name: Trust home CA
|
||||||
|
if: ${{ vars.HOME_CA != '' }}
|
||||||
|
env:
|
||||||
|
HOME_CA: ${{ vars.HOME_CA }}
|
||||||
|
run: |
|
||||||
|
printf '%s\n' "$HOME_CA" > /usr/local/share/ca-certificates/home-ca.crt
|
||||||
|
update-ca-certificates
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
env:
|
||||||
|
NODE_EXTRA_CA_CERTS: /etc/ssl/certs/ca-certificates.crt
|
||||||
|
|
||||||
|
- name: Log in to registry
|
||||||
|
run: |
|
||||||
|
REGISTRY="${SERVER_URL#https://}"
|
||||||
|
echo "REGISTRY=$REGISTRY" >> "$GITHUB_ENV"
|
||||||
|
echo "IMAGE=$REGISTRY/${REPO,,}" >> "$GITHUB_ENV"
|
||||||
|
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY" -u "$OWNER" --password-stdin
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: docker build -t "$IMAGE:latest" -t "$IMAGE:${SHA::12}" .
|
||||||
|
|
||||||
|
- name: Push
|
||||||
|
run: docker push --all-tags "$IMAGE"
|
||||||
|
|
||||||
|
# Redeploy on the Docker host. The key's forced command on the host
|
||||||
|
# only accepts allowlisted site names, so this can't run anything else.
|
||||||
|
# Skipped when DEPLOY_HOST isn't set.
|
||||||
|
- name: Deploy
|
||||||
|
if: ${{ vars.DEPLOY_HOST != '' }}
|
||||||
|
env:
|
||||||
|
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
|
||||||
|
DEPLOY_KNOWN_HOSTS: ${{ vars.DEPLOY_KNOWN_HOSTS }}
|
||||||
|
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
|
run: |
|
||||||
|
install -m 700 -d ~/.ssh
|
||||||
|
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
|
||||||
|
chmod 600 ~/.ssh/deploy_key
|
||||||
|
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
||||||
|
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=yes "root@$DEPLOY_HOST" theoldfitz
|
||||||
+15
-4
@@ -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/
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# Build stage: compile Sass and assemble the allowlisted dist/ folder.
|
||||||
|
# config.php never enters the image (.dockerignore), and build-dist.js
|
||||||
|
# doesn't copy it anyway.
|
||||||
|
FROM node:24-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run dist
|
||||||
|
|
||||||
|
# Runtime stage. Production php.ini and expose_php = Off are baked in here,
|
||||||
|
# replacing the startup `command:` in docker-compose.yaml. Files are owned
|
||||||
|
# by root with 644/755 (normalized by build-dist.js), so www-data can read
|
||||||
|
# the site but not modify it; data/ and config/ arrive as mounts.
|
||||||
|
FROM php:8.5-apache
|
||||||
|
RUN cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" \
|
||||||
|
&& printf 'expose_php = Off\n' > "$PHP_INI_DIR/conf.d/zz-hardening.ini"
|
||||||
|
COPY --from=build /src/dist/ /var/www/html/
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../inc/auth.php';
|
||||||
|
require_once __DIR__ . '/../inc/assets.php';
|
||||||
|
require_once __DIR__ . '/../inc/admin-head.php';
|
||||||
|
require_once __DIR__ . '/../inc/admin-nav.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']);
|
||||||
|
// 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">
|
||||||
|
|
||||||
|
<?php render_admin_head('My Account'); ?>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<?php render_admin_nav($user, 'account'); ?>
|
||||||
|
|
||||||
|
<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>
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
+182
@@ -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/admin-head.php';
|
||||||
|
require_once __DIR__ . '/../inc/admin-nav.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">
|
||||||
|
|
||||||
|
<?php render_admin_head('Hours'); ?>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<?php render_admin_nav($user, 'hours'); ?>
|
||||||
|
|
||||||
|
<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 hours-note">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 hours-preview-intro">This is how the schedule above will read on the site once saved.</p>
|
||||||
|
|
||||||
|
<div class="hours-preview">
|
||||||
|
<div class="hours-preview-card">
|
||||||
|
<dl class="hours-list">
|
||||||
|
<?php foreach (hours_card_rows($hoursByDay) as $row): ?>
|
||||||
|
<div class="hours-row">
|
||||||
|
<dt><?= htmlspecialchars($row['label']) ?></dt>
|
||||||
|
<dd><?= htmlspecialchars($row['text']) ?></dd>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</dl>
|
||||||
|
<?php $kitchenNote = hours_kitchen_note($hoursByDay); ?>
|
||||||
|
<p class="note">
|
||||||
|
<?php if ($kitchenNote): ?><strong><?= htmlspecialchars($kitchenNote) ?></strong> — bar snacks stay out later. <?php endif; ?>Holiday hours may vary; check our Facebook page for the latest.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="hours-preview-footer">
|
||||||
|
<p class="col-title">Hours</p>
|
||||||
|
<div class="hours-mini">
|
||||||
|
<?php foreach (hours_footer_lines($hoursByDay) as $line): ?>
|
||||||
|
<span><?= htmlspecialchars($line) ?></span>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="hours.js" defer></script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Announcement editor dialog: the server flags it for ?new / ?edit URLs.
|
||||||
|
// Closing it any way (× link, Esc) drops the query string, so a reload
|
||||||
|
// doesn't reopen it.
|
||||||
|
const annModal = document.getElementById('modal-ann');
|
||||||
|
if (annModal?.hasAttribute('data-open-on-load')) {
|
||||||
|
annModal.showModal();
|
||||||
|
annModal.addEventListener('close', () => {
|
||||||
|
history.replaceState(null, '', 'index.php');
|
||||||
|
});
|
||||||
|
}
|
||||||
+298
@@ -0,0 +1,298 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../inc/auth.php';
|
||||||
|
require_once __DIR__ . '/../inc/assets.php';
|
||||||
|
require_once __DIR__ . '/../inc/admin-head.php';
|
||||||
|
require_once __DIR__ . '/../inc/admin-nav.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">
|
||||||
|
<?php render_admin_head('Announcements'); ?>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<?php render_admin_nav($user, 'announcements'); ?>
|
||||||
|
|
||||||
|
<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 ann-summary">
|
||||||
|
<?= htmlspecialchars($ann['message']) ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div data-label="Type">
|
||||||
|
<span class="eyebrow"><?= htmlspecialchars($ann['type']) ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="font-mono ann-schedule" data-label="Schedule">
|
||||||
|
<?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 class="ann-placements" data-label="Placements">
|
||||||
|
<?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" class="inline-form 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: Announcement Editor (Create / Edit). The server marks it
|
||||||
|
data-open-on-load for ?new / ?edit URLs and index.js opens it with
|
||||||
|
showModal(); saving is still a plain form POST + reload. -->
|
||||||
|
<dialog class="admin-modal" id="modal-ann" aria-labelledby="modal-ann-title"<?= ($isCreatingNew || $editItem) ? ' data-open-on-load' : '' ?>>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="modal-ann-title"><?= $editItem ? '✏️ Edit Announcement' : '✨ New Announcement' ?></h2>
|
||||||
|
<a href="index.php" class="modal-close" aria-label="Close dialog">×</a>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="index.php">
|
||||||
|
<input type="hidden" name="action" value="save_announcement">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||||
|
<input type="hidden" name="announcement_id" value="<?= (int)($editItem['id'] ?? 0) ?>">
|
||||||
|
|
||||||
|
<div class="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 ann-field-hint">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 ann-field-hint">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 & 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>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<script src="index.js" defer></script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../inc/auth.php';
|
||||||
|
require_once __DIR__ . '/../inc/login-throttle.php';
|
||||||
|
require_once __DIR__ . '/../inc/assets.php';
|
||||||
|
require_once __DIR__ . '/../inc/admin-head.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">
|
||||||
|
<?php render_admin_head('Sign In'); ?>
|
||||||
|
<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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../inc/auth.php';
|
||||||
|
logout_user();
|
||||||
|
header('Location: login.php');
|
||||||
|
exit;
|
||||||
+569
@@ -0,0 +1,569 @@
|
|||||||
|
// 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');
|
||||||
|
const catModal = document.getElementById('modal-cat');
|
||||||
|
|
||||||
|
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.showModal();
|
||||||
|
nameField.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeItemModal() {
|
||||||
|
itemModal.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
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.showModal();
|
||||||
|
nameField.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCategoryModal() {
|
||||||
|
catModal.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escapes for both text and quoted-attribute positions. The old version
|
||||||
|
// (textContent → innerHTML) didn't escape quotes, so a name like
|
||||||
|
// The "Big" Burger broke out of an aria-label="..." attribute.
|
||||||
|
function escapeHtml(text) {
|
||||||
|
return String(text ?? '')
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
+395
@@ -0,0 +1,395 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../inc/auth.php';
|
||||||
|
require_once __DIR__ . '/../inc/assets.php';
|
||||||
|
require_once __DIR__ . '/../inc/admin-head.php';
|
||||||
|
require_once __DIR__ . '/../inc/admin-nav.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">
|
||||||
|
|
||||||
|
<?php render_admin_head('Menu'); ?>
|
||||||
|
|
||||||
|
<body data-csrf-token="<?= htmlspecialchars(get_csrf_token(), ENT_QUOTES, 'UTF-8') ?>">
|
||||||
|
|
||||||
|
<?php render_admin_nav($user, 'menu'); ?>
|
||||||
|
|
||||||
|
<main class="dashboard-layout">
|
||||||
|
<div class="container">
|
||||||
|
<div class="dash-header">
|
||||||
|
<p class="eyebrow">Food & 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: Menu Item -->
|
||||||
|
<dialog class="admin-modal" id="modal-item" aria-labelledby="modal-item-title">
|
||||||
|
<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">×</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>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Modal: Category -->
|
||||||
|
<dialog class="admin-modal" id="modal-cat" aria-labelledby="modal-cat-title">
|
||||||
|
<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">×</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>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<script src="menu.js" defer></script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
+334
@@ -0,0 +1,334 @@
|
|||||||
|
// 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');
|
||||||
|
const passwordModal = document.getElementById('modal-password');
|
||||||
|
const editModal = document.getElementById('modal-edit');
|
||||||
|
|
||||||
|
// Escapes for both text and quoted-attribute positions. The old version
|
||||||
|
// (textContent → innerHTML) didn't escape quotes, so a name like
|
||||||
|
// The "Big" Burger broke out of an aria-label="..." attribute.
|
||||||
|
function escapeHtml(text) {
|
||||||
|
return String(text ?? '')
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Native <dialog>: close() hides it (not rendered, so its fields can't keep
|
||||||
|
// focus or trigger autofill) and returns focus to whatever opened it.
|
||||||
|
// Esc closes it too, with no extra code.
|
||||||
|
function closeModal(modal) {
|
||||||
|
modal.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
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.showModal();
|
||||||
|
(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.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
+439
@@ -0,0 +1,439 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../inc/auth.php';
|
||||||
|
require_once __DIR__ . '/../inc/assets.php';
|
||||||
|
require_once __DIR__ . '/../inc/admin-head.php';
|
||||||
|
require_once __DIR__ . '/../inc/admin-nav.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">
|
||||||
|
|
||||||
|
<?php render_admin_head('Users'); ?>
|
||||||
|
|
||||||
|
<body data-csrf-token="<?= htmlspecialchars(get_csrf_token(), ENT_QUOTES, 'UTF-8') ?>" data-current-user-id="<?= (int) $user['id'] ?>">
|
||||||
|
|
||||||
|
<?php render_admin_nav($user, 'users'); ?>
|
||||||
|
|
||||||
|
<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: Add User -->
|
||||||
|
<dialog class="admin-modal" id="modal-user" aria-labelledby="modal-user-title">
|
||||||
|
<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">×</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>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Modal: Edit User -->
|
||||||
|
<dialog class="admin-modal" id="modal-edit" aria-labelledby="modal-edit-title">
|
||||||
|
<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">×</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>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Modal: Change Password -->
|
||||||
|
<dialog class="admin-modal" id="modal-password" aria-labelledby="modal-password-title">
|
||||||
|
<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">×</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>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<script src="users.js" defer></script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
@@ -0,0 +1 @@
|
|||||||
|
Require all denied
|
||||||
@@ -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
@@ -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);
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Require all denied
|
||||||
+52
-32
@@ -1,45 +1,65 @@
|
|||||||
|
# The Old Fitz — compose for any Docker host (test or production).
|
||||||
|
#
|
||||||
|
# The site itself is baked into the image (see Dockerfile); the host only
|
||||||
|
# holds state. Expected layout, one folder per site:
|
||||||
|
# theoldfitz/
|
||||||
|
# docker-compose.yaml (this file)
|
||||||
|
# .env (FITZ_IMAGE, and PROXY_NETWORK if not "proxy")
|
||||||
|
# config/config.php (live secrets — never in the image)
|
||||||
|
# data/ (fitz.sqlite + cache/ — never in the image)
|
||||||
|
#
|
||||||
|
# Deploy: `docker compose pull && docker compose up -d`.
|
||||||
|
# Manual fallback from a checkout of the repo: `docker compose up -d --build`.
|
||||||
|
#
|
||||||
|
# First run: create data/ and `chown 33:33 data` (www-data in the image).
|
||||||
|
# Copy config.example.php to config/config.php, fill it in, and make it
|
||||||
|
# readable by www-data (`chown root:33` + `chmod 640`). Then create the
|
||||||
|
# first admin with bin/create-user.php (see its header).
|
||||||
|
#
|
||||||
|
# The CasaOS dev host keeps its own copy of this file in the CasaOS app
|
||||||
|
# settings, with CasaOS paths and its x-casaos block.
|
||||||
|
|
||||||
name: the-old-fitz
|
name: the-old-fitz
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
nginxproxymanager:
|
proxy:
|
||||||
name: nginxproxymanager
|
|
||||||
external: true
|
external: true
|
||||||
|
# The reverse proxy's Docker network (NPM or Caddy). Set PROXY_NETWORK
|
||||||
|
# in .env if it isn't called "proxy".
|
||||||
|
name: ${PROXY_NETWORK:-proxy}
|
||||||
|
|
||||||
services:
|
services:
|
||||||
php-server:
|
the-old-fitz:
|
||||||
image: php:8.3-apache
|
# Servers set FITZ_IMAGE in .env to the registry image, e.g.
|
||||||
|
# gitea.example.com/david/the-old-fitz:latest. Unset, it falls back to a
|
||||||
|
# local tag so `up --build` works from a checkout.
|
||||||
|
image: ${FITZ_IMAGE:-the-old-fitz:local}
|
||||||
|
build: .
|
||||||
container_name: the-old-fitz
|
container_name: the-old-fitz
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- nginxproxymanager
|
- proxy
|
||||||
volumes:
|
volumes:
|
||||||
|
# Database and cache live outside the web root so Apache can't serve
|
||||||
|
# them, and outside the image so deploys never touch them.
|
||||||
- type: bind
|
- type: bind
|
||||||
source: /DATA/AppData/theoldfitz/www
|
source: ./data
|
||||||
target: /var/www/html
|
target: /var/www/data
|
||||||
|
# Config is mounted as a directory, not a single file: a single-file
|
||||||
|
# bind mount pins the inode, so an editor that saves by replacing the
|
||||||
|
# file leaves the container reading the old copy until a restart.
|
||||||
|
- type: bind
|
||||||
|
source: ./config
|
||||||
|
target: /var/www/config
|
||||||
|
read_only: true
|
||||||
environment:
|
environment:
|
||||||
- TZ=America/Indiana/Indianapolis
|
- TZ=America/Indiana/Indianapolis
|
||||||
labels:
|
- FITZ_DB_PATH=/var/www/data/fitz.sqlite
|
||||||
icon: https://www.php.net/images/logos/php-logo.svg
|
- FITZ_CACHE_DIR=/var/www/data/cache
|
||||||
|
- FITZ_CONFIG_PATH=/var/www/config/config.php
|
||||||
x-casaos:
|
# Cap container logs so PHP's error log can't slowly fill the disk.
|
||||||
architectures:
|
logging:
|
||||||
- amd64
|
driver: json-file
|
||||||
- arm64
|
options:
|
||||||
author: self
|
max-size: 10m
|
||||||
category: Developer
|
max-file: "3"
|
||||||
description:
|
|
||||||
en_us: PHP web server running Apache, proxied through Nginx Proxy Manager.
|
|
||||||
developer: PHP
|
|
||||||
hostname: theoldfitz.daviddyer.me
|
|
||||||
icon: https://www.php.net/images/logos/php-logo.svg
|
|
||||||
id: self.php-server
|
|
||||||
index: /
|
|
||||||
is_uncontrolled: false
|
|
||||||
main: php-server
|
|
||||||
port_map: "80"
|
|
||||||
scheme: http
|
|
||||||
tagline:
|
|
||||||
en_us: PHP 8.3 + Apache web server.
|
|
||||||
thumbnail: https://www.php.net/images/logos/php-logo.svg
|
|
||||||
title:
|
|
||||||
en_us: PHP Server
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/assets.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared <head> for every admin page. $title is the page's own name; the
|
||||||
|
* site suffix is added here so every tab reads "<Page> | The Old Fitz Admin".
|
||||||
|
*/
|
||||||
|
function render_admin_head(string $title): void {
|
||||||
|
?>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<title><?= htmlspecialchars($title) ?> | 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>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared admin navigation bar. Every admin page calls
|
||||||
|
* render_admin_nav($user, '<key>') where it used to repeat the markup.
|
||||||
|
*
|
||||||
|
* $active is one of the ADMIN_NAV_LINKS keys, or 'account' for the
|
||||||
|
* My Account page (which highlights the username link instead).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ADMIN_NAV_LINKS = [
|
||||||
|
// key => [href, label, admin-only]
|
||||||
|
'announcements' => ['index.php', 'Announcements', false],
|
||||||
|
'menu' => ['menu.php', 'Menu', false],
|
||||||
|
'users' => ['users.php', 'Users', true],
|
||||||
|
'hours' => ['hours.php', 'Hours', false],
|
||||||
|
];
|
||||||
|
|
||||||
|
function render_admin_nav(array $user, string $active): void {
|
||||||
|
$isAdmin = ($user['role'] ?? '') === 'admin';
|
||||||
|
$current = static fn (string $key): string =>
|
||||||
|
$key === $active ? ' class="active" aria-current="page"' : '';
|
||||||
|
?>
|
||||||
|
<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">
|
||||||
|
<?php foreach (ADMIN_NAV_LINKS as $key => [$href, $label, $adminOnly]): ?>
|
||||||
|
<?php if (!$adminOnly || $isAdmin): ?>
|
||||||
|
<a href="<?= $href ?>"<?= $current($key) ?>><?= $label ?></a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<div class="admin-user">
|
||||||
|
<a href="../" target="_blank" rel="noopener">View Public Site ↗</a>
|
||||||
|
<a href="account.php" class="admin-account-link<?= $active === 'account' ? ' active' : '' ?>" title="My Account"<?= $active === 'account' ? ' aria-current="page"' : '' ?>><?= htmlspecialchars($user['username']) ?></a>
|
||||||
|
<a href="logout.php">Sign Out</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 : '';
|
||||||
|
}
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Auth & session helper
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/security-headers.php';
|
||||||
|
send_security_headers();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database connection & schema management for SQLite
|
||||||
|
*/
|
||||||
|
|
||||||
|
function get_db(): PDO {
|
||||||
|
static $pdo = null;
|
||||||
|
if ($pdo !== null) {
|
||||||
|
return $pdo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In Docker, FITZ_DB_PATH points outside the web root (see
|
||||||
|
// docker-compose.yaml), so Apache can never serve the file. Local dev
|
||||||
|
// falls back to db/ in the project, which the dev router refuses to serve.
|
||||||
|
$dbPath = getenv('FITZ_DB_PATH') ?: __DIR__ . '/../db/fitz.sqlite';
|
||||||
|
$dbDir = dirname($dbPath);
|
||||||
|
if (!is_dir($dbDir)) {
|
||||||
|
mkdir($dbDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$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)");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In Docker, FITZ_CACHE_DIR points into the writable data/ mount, outside
|
||||||
|
// the web root, so deploys don't wipe it and www/ can stay read-only to
|
||||||
|
// www-data. Local dev falls back to cache/ in the project root.
|
||||||
|
$cacheDir = getenv('FITZ_CACHE_DIR') ?: __DIR__ . '/../cache';
|
||||||
|
$cacheFile = $cacheDir . '/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();
|
||||||
|
|
||||||
|
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
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the site config. In Docker, FITZ_CONFIG_PATH points outside the
|
||||||
|
* web root (see docker-compose.yaml), so a deploy that replaces www/ can
|
||||||
|
* never overwrite or ship it. Local dev falls back to config.php in the
|
||||||
|
* project root.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require getenv('FITZ_CONFIG_PATH') ?: __DIR__ . '/../config.php';
|
||||||
@@ -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
@@ -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]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security headers for every page, sent from PHP so they apply the same
|
||||||
|
* under NPM, Caddy, or local dev. Public pages call send_security_headers()
|
||||||
|
* directly; admin pages get it through inc/auth.php.
|
||||||
|
*
|
||||||
|
* Static files (CSS, JS, images) are served by Apache and don't pass
|
||||||
|
* through here — that's fine, since the CSP only matters on documents.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
}
|
||||||
|
|
||||||
|
function send_security_headers(): void {
|
||||||
|
if (headers_sent()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Under `npm run dev`, browser-sync injects an inline <script> to load
|
||||||
|
// its live-reload client and talks over a websocket. Allow exactly that
|
||||||
|
// in dev only; production stays strict. Inline *styles* are blocked in
|
||||||
|
// dev too, so a stray style="" still shows up while developing.
|
||||||
|
$isDev = PHP_SAPI === 'cli-server';
|
||||||
|
|
||||||
|
$csp = [
|
||||||
|
"default-src 'self'",
|
||||||
|
"script-src 'self'" . ($isDev ? " 'unsafe-inline'" : ''),
|
||||||
|
"style-src 'self' https://fonts.googleapis.com",
|
||||||
|
"font-src https://fonts.gstatic.com",
|
||||||
|
"img-src 'self'",
|
||||||
|
"connect-src 'self'" . ($isDev ? ' ws: wss:' : ''),
|
||||||
|
"object-src 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
];
|
||||||
|
|
||||||
|
header('Content-Security-Policy: ' . implode('; ', $csp));
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
header('X-Frame-Options: DENY');
|
||||||
|
header('Referrer-Policy: strict-origin-when-cross-origin');
|
||||||
|
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
|
||||||
|
|
||||||
|
// HSTS only over HTTPS (browsers ignore it on HTTP anyway). No
|
||||||
|
// includeSubDomains: this site is one subdomain of daviddyer.me and
|
||||||
|
// shouldn't dictate policy for the others.
|
||||||
|
if (request_is_https()) {
|
||||||
|
header('Strict-Transport-Security: max-age=31536000');
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
@@ -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]);
|
||||||
|
}
|
||||||
@@ -1,8 +1,16 @@
|
|||||||
<?php
|
<?php
|
||||||
require __DIR__ . '/config.php';
|
require __DIR__ . '/inc/load-config.php';
|
||||||
require __DIR__ . '/inc/facebook-stats.php';
|
require __DIR__ . '/inc/assets.php';
|
||||||
$fbStats = fritz_facebook_stats();
|
require __DIR__ . '/inc/security-headers.php';
|
||||||
|
send_security_headers();
|
||||||
|
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 +18,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 +52,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 & Visit</a>
|
<a href="#visit">Hours & 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&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&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 +108,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 +122,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 +145,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">
|
||||||
<div class="hours-row">
|
<?php foreach (hours_card_rows($hoursByDay) as $row): ?>
|
||||||
<dt>Thursday</dt>
|
<div class="hours-row">
|
||||||
<dd>4:00 – 10:00 PM</dd>
|
<dt><?= htmlspecialchars($row['label']) ?></dt>
|
||||||
</div>
|
<dd><?= htmlspecialchars($row['text']) ?></dd>
|
||||||
<div class="hours-row">
|
</div>
|
||||||
<dt>Friday</dt>
|
<?php endforeach; ?>
|
||||||
<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>
|
|
||||||
</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,14 +203,23 @@ $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">
|
|
||||||
<p class="eyebrow">Live Music</p>
|
<div class="music-grid">
|
||||||
<h2>Some nights, there's music.</h2>
|
<div class="music-photo-wrap reveal">
|
||||||
</div>
|
<img src="./assets/jesse-hart.jpg" alt="Jesse Hart performing live acoustic music" class="music-photo" loading="lazy">
|
||||||
<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>
|
</div>
|
||||||
<div class="hero-ctas reveal">
|
|
||||||
<a class="btn btn-outline" href="https://www.facebook.com/profile.php?id=100086635646712" target="_blank" rel="noopener">Follow on Facebook</a>
|
<div class="music-content reveal">
|
||||||
<a class="btn btn-outline" href="https://www.instagram.com/theoldfitzpub/?hl=en" target="_blank" rel="noopener">Follow on Instagram</a>
|
<div class="section-head">
|
||||||
|
<p class="eyebrow">Live Music</p>
|
||||||
|
<h2>Some nights, there's music.</h2>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -188,10 +231,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 +249,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 +257,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 +288,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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/inc/load-config.php';
|
||||||
|
require __DIR__ . '/inc/assets.php';
|
||||||
|
require __DIR__ . '/inc/security-headers.php';
|
||||||
|
send_security_headers();
|
||||||
|
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 & 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&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 menu-hero">
|
||||||
|
<p class="eyebrow">The Old Fitz Public House</p>
|
||||||
|
<h1 class="menu-hero-title">Pub Menu</h1>
|
||||||
|
<div class="rule-row menu-hero-rule">
|
||||||
|
<span class="line"></span>
|
||||||
|
<img class="knot" src="./assets/knot.png" alt="">
|
||||||
|
<span class="line"></span>
|
||||||
|
</div>
|
||||||
|
<p class="muted menu-hero-intro">
|
||||||
|
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">
|
||||||
|
<div class="container menu-page-container">
|
||||||
|
<?php if ($menuEnabled && !empty($menuCategories)): ?>
|
||||||
|
<div class="menu-grid menu-grid-flush">
|
||||||
|
<?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 menu-empty">
|
||||||
|
<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">
|
||||||
|
<p class="muted menu-disclaimer-text">
|
||||||
|
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 menu-disclaimer-ctas">
|
||||||
|
<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>
|
||||||
Generated
+2430
File diff suppressed because it is too large.
Load diff
@@ -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 scripts/dev-router.php",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
#!/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.php,
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* config.php is deliberately excluded: it holds live secrets and lives
|
||||||
|
* outside the web root on the server (see FITZ_CONFIG_PATH in
|
||||||
|
* docker-compose.yaml), so a deploy can never overwrite it.
|
||||||
|
*
|
||||||
|
* 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',
|
||||||
|
];
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize permissions: 755 for directories, 644 for files. Copies keep
|
||||||
|
// the source file's mode, so a file that's owner-only on the dev machine
|
||||||
|
// (e.g. after an in-place edit) would ship unreadable to Apache's
|
||||||
|
// www-data and 500 the page. Forcing the modes here means local
|
||||||
|
// permissions can never break a deploy.
|
||||||
|
function normalizePermissions(dir) {
|
||||||
|
fs.chmodSync(dir, 0o755);
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
normalizePermissions(full);
|
||||||
|
} else {
|
||||||
|
fs.chmodSync(full, 0o644);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizePermissions(DIST);
|
||||||
|
|
||||||
|
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)}/`);
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Router for local dev (`php -S localhost:8000 scripts/dev-router.php`).
|
||||||
|
*
|
||||||
|
* PHP's built-in server ignores .htaccess, so without this it would serve
|
||||||
|
* the SQLite database, includes, and project files to anyone who can
|
||||||
|
* reach it — including other devices on the LAN via browser-sync.
|
||||||
|
* Anything not blocked falls through to the built-in server as normal.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$path = rawurldecode(parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/');
|
||||||
|
$path = preg_replace('#/+#', '/', $path);
|
||||||
|
|
||||||
|
// Case-insensitive: macOS's filesystem is, so /DB/fitz.sqlite would match.
|
||||||
|
$blocked = '#^/(?:db|inc|bin|scripts|src|dist|node_modules)(?:/|$)'
|
||||||
|
. '|(?:^|/)\.'
|
||||||
|
. '|^/(?:package(?:-lock)?\.json|docker-compose\.ya?ml|config(?:\.example)?\.php)$#i';
|
||||||
|
|
||||||
|
if (preg_match($blocked, $path)) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo 'Not Found';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apache (mod_dir) redirects /admin to /admin/; the built-in server just
|
||||||
|
// serves admin/index.php at /admin. Every relative link on that page then
|
||||||
|
// resolves against / instead of /admin/ — "menu.php" lands on the public
|
||||||
|
// menu, "users.php" 404s. Match Apache so dev behaves like production.
|
||||||
|
if ($path !== '/' && !str_ends_with($path, '/') && is_dir(__DIR__ . '/..' . $path)) {
|
||||||
|
$query = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_QUERY);
|
||||||
|
header('Location: ' . $path . '/' . ($query ? '?' . $query : ''), true, 301);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/* ---------- admin modal dialogs ---------- */
|
||||||
|
/* Native <dialog> modals. The browser handles the overlay (::backdrop),
|
||||||
|
focus trapping, inertness of the page behind, and Esc. A closed dialog
|
||||||
|
is display: none, so its fields can't hold focus or trigger autofill.
|
||||||
|
Open/close fades rely on @starting-style for the entry and
|
||||||
|
allow-discrete to keep display and overlay alive through the exit. */
|
||||||
|
.admin-modal {
|
||||||
|
background: var(--surface);
|
||||||
|
color: inherit;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
width: calc(100% - 3rem);
|
||||||
|
max-width: 36rem;
|
||||||
|
max-height: 90vh;
|
||||||
|
margin: auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 2rem;
|
||||||
|
box-shadow: 0 1.5rem 3rem rgba(0, 0, 0, 0.6);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(0.75rem);
|
||||||
|
transition:
|
||||||
|
opacity .2s ease,
|
||||||
|
transform .2s ease,
|
||||||
|
overlay .2s ease allow-discrete,
|
||||||
|
display .2s ease allow-discrete;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-modal[open] {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-modal::backdrop {
|
||||||
|
background-color: rgba(15, 12, 10, 0);
|
||||||
|
backdrop-filter: blur(0);
|
||||||
|
transition:
|
||||||
|
background-color .2s ease,
|
||||||
|
backdrop-filter .2s ease,
|
||||||
|
overlay .2s ease allow-discrete,
|
||||||
|
display .2s ease allow-discrete;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-modal[open]::backdrop {
|
||||||
|
background-color: rgba(15, 12, 10, 0.8);
|
||||||
|
backdrop-filter: blur(0.25rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
@starting-style {
|
||||||
|
.admin-modal[open] {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(0.75rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-modal[open]::backdrop {
|
||||||
|
background-color: rgba(15, 12, 10, 0);
|
||||||
|
backdrop-filter: blur(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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selects: drop the native (rounded, shorter) control so they match the
|
||||||
|
text inputs, and draw the chevron with two gradients instead of an
|
||||||
|
SVG data URI — it can use the theme tokens and needs no `data:` in the
|
||||||
|
CSP's img-src. color-scheme gives the native option list dark colors. */
|
||||||
|
select.form-control {
|
||||||
|
appearance: none;
|
||||||
|
color-scheme: dark;
|
||||||
|
cursor: pointer;
|
||||||
|
padding-right: 2.5rem;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(45deg, transparent 50%, var(--gold) 50%),
|
||||||
|
linear-gradient(135deg, var(--gold) 50%, transparent 50%);
|
||||||
|
background-position:
|
||||||
|
calc(100% - 1.2rem) 50%,
|
||||||
|
calc(100% - 0.85rem) 50%;
|
||||||
|
background-size: 0.35rem 0.35rem;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
select.form-control:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/* ---------- admin: announcements ---------- */
|
||||||
|
/* Formerly inline style="" attributes on admin/index.php — moved here so
|
||||||
|
the CSP can forbid inline styles. */
|
||||||
|
.ann-summary {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
max-width: 32ch;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ann-schedule {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ann-placements {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ann-field-hint {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-form {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
/* ---------- 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Formerly inline style="" attributes on admin/hours.php. */
|
||||||
|
.hours-note {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hours-preview-intro {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/* 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-top: 0;
|
||||||
|
padding-bottom: 5.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Formerly inline style="" attributes on menu.php — moved here so the
|
||||||
|
CSP can forbid inline styles. */
|
||||||
|
.menu-hero {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-hero-title {
|
||||||
|
font-size: clamp(2.4rem, 5vw, 3.8rem);
|
||||||
|
color: var(--gold-bright);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-hero-rule {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-hero-intro {
|
||||||
|
max-width: 52ch;
|
||||||
|
margin: 0 auto;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-page-container {
|
||||||
|
max-width: 62rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-grid-flush {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-disclaimer {
|
||||||
|
margin-top: 3.5rem;
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 2rem;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-disclaimer-text {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 68ch;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-disclaimer-ctas {
|
||||||
|
margin-top: 2rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// 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-announcements";
|
||||||
|
@use "pages/admin-account";
|
||||||
+2272
-380
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user