Compare commits
10
Commits
242c5165e1
...
69be043f9d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69be043f9d | ||
|
|
a7fb530061 | ||
|
|
cf9fed749b | ||
|
|
7cac2ebcae | ||
|
|
e2c47bed61 | ||
|
|
759c22afaf | ||
|
|
bb65ecd43c | ||
|
|
c7b836bcf2 | ||
|
|
3286bce6b1 | ||
|
|
02551eebbc |
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,83 @@
|
|||||||
|
# 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: debian
|
||||||
|
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
|
||||||
|
|
||||||
|
# node:24-trixie (Debian 13) has git and node but no Docker CLI. Install
|
||||||
|
# the CLI and buildx from Docker's apt repo; the daemon is the runner's.
|
||||||
|
- name: Install Docker CLI
|
||||||
|
run: |
|
||||||
|
install -m 0755 -d /etc/apt/keyrings
|
||||||
|
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
|
||||||
|
chmod a+r /etc/apt/keyrings/docker.asc
|
||||||
|
cat > /etc/apt/sources.list.d/docker.sources <<SRC
|
||||||
|
Types: deb
|
||||||
|
URIs: https://download.docker.com/linux/debian
|
||||||
|
Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")
|
||||||
|
Components: stable
|
||||||
|
Signed-By: /etc/apt/keyrings/docker.asc
|
||||||
|
SRC
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends docker-ce-cli docker-buildx-plugin
|
||||||
|
|
||||||
|
- 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
|
||||||
+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/
|
||||||
+4
-31
@@ -3,6 +3,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
require_once __DIR__ . '/../inc/auth.php';
|
require_once __DIR__ . '/../inc/auth.php';
|
||||||
require_once __DIR__ . '/../inc/assets.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';
|
require_once __DIR__ . '/../inc/users.php';
|
||||||
|
|
||||||
// Any signed-in user can change their own username and/or password here,
|
// Any signed-in user can change their own username and/or password here,
|
||||||
@@ -80,46 +82,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$justSaved = !$_POST && isset($_GET['saved']);
|
$justSaved = !$_POST && isset($_GET['saved']);
|
||||||
$isAdmin = ($user['role'] ?? '') === 'admin';
|
|
||||||
// After a failed save, keep the username they typed in the field.
|
// After a failed save, keep the username they typed in the field.
|
||||||
$usernameValue = $_POST ? trim(account_post_str('username')) : $user['username'];
|
$usernameValue = $_POST ? trim(account_post_str('username')) : $user['username'];
|
||||||
?>
|
?>
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<?php render_admin_head('My Account'); ?>
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>My Account | The Old Fitz Admin</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,600;1,9..144,400&family=Karla:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="../styles.css<?= asset_version('styles.css') ?>">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<nav class="admin-nav">
|
<?php render_admin_nav($user, 'account'); ?>
|
||||||
<div class="container admin-nav-inner">
|
|
||||||
<a href="index.php" class="admin-brand">
|
|
||||||
<img src="../assets/knot.png" alt="" class="knot">
|
|
||||||
<span>The Old Fitz Admin</span>
|
|
||||||
</a>
|
|
||||||
<div class="admin-nav-links">
|
|
||||||
<a href="index.php">Announcements</a>
|
|
||||||
<a href="menu.php">Menu</a>
|
|
||||||
<?php if ($isAdmin): ?>
|
|
||||||
<a href="users.php">Users</a>
|
|
||||||
<?php endif; ?>
|
|
||||||
<a href="hours.php">Hours</a>
|
|
||||||
</div>
|
|
||||||
<div class="admin-user">
|
|
||||||
<a href="../" target="_blank" rel="noopener">View Public Site ↗</a>
|
|
||||||
<a href="account.php" class="admin-account-link active" title="My Account"><?= htmlspecialchars($user['username']) ?></a>
|
|
||||||
<a href="logout.php">Sign Out</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main class="dashboard-layout">
|
<main class="dashboard-layout">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|||||||
+6
-32
@@ -3,6 +3,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
require_once __DIR__ . '/../inc/auth.php';
|
require_once __DIR__ . '/../inc/auth.php';
|
||||||
require_once __DIR__ . '/../inc/assets.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_once __DIR__ . '/../inc/hours.php';
|
||||||
|
|
||||||
require_auth();
|
require_auth();
|
||||||
@@ -66,39 +68,11 @@ $justSaved = !$_POST && isset($_GET['saved']);
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<?php render_admin_head('Hours'); ?>
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Hours | The Old Fitz Admin</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,600;1,9..144,400&family=Karla:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="../styles.css<?= asset_version('styles.css') ?>">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<nav class="admin-nav">
|
<?php render_admin_nav($user, 'hours'); ?>
|
||||||
<div class="container admin-nav-inner">
|
|
||||||
<a href="index.php" class="admin-brand">
|
|
||||||
<img src="../assets/knot.png" alt="" class="knot">
|
|
||||||
<span>The Old Fitz Admin</span>
|
|
||||||
</a>
|
|
||||||
<div class="admin-nav-links">
|
|
||||||
<a href="index.php">Announcements</a>
|
|
||||||
<a href="menu.php">Menu</a>
|
|
||||||
<?php if (($user['role'] ?? '') === 'admin'): ?>
|
|
||||||
<a href="users.php">Users</a>
|
|
||||||
<?php endif; ?>
|
|
||||||
<a href="hours.php" class="active">Hours</a>
|
|
||||||
</div>
|
|
||||||
<div class="admin-user">
|
|
||||||
<a href="../" target="_blank" rel="noopener">View Public Site ↗</a>
|
|
||||||
<a href="account.php" class="admin-account-link" title="My Account"><?= htmlspecialchars($user['username']) ?></a>
|
|
||||||
<a href="logout.php">Sign Out</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main class="dashboard-layout">
|
<main class="dashboard-layout">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -157,7 +131,7 @@ $justSaved = !$_POST && isset($_GET['saved']);
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="muted field-hint" style="margin-top: 1.25rem;">Leave "Kitchen Closes" blank on a day if the kitchen doesn't have a separate cutoff from the bar. Special closures (holidays, weather) are handled on the <a href="index.php">Announcements</a> page instead of here.</p>
|
<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">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-primary">Save Hours</button>
|
<button type="submit" class="btn btn-primary">Save Hours</button>
|
||||||
@@ -171,7 +145,7 @@ $justSaved = !$_POST && isset($_GET['saved']);
|
|||||||
<span>👀 Public Site Preview</span>
|
<span>👀 Public Site Preview</span>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted" style="margin-bottom: 1.25rem;">This is how the schedule above will read on the site once saved.</p>
|
<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">
|
||||||
<div class="hours-preview-card">
|
<div class="hours-preview-card">
|
||||||
|
|||||||
@@ -42,3 +42,14 @@ document.addEventListener('submit', (e) => {
|
|||||||
e.preventDefault();
|
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');
|
||||||
|
});
|
||||||
|
}
|
||||||
+15
-43
@@ -1,6 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once __DIR__ . '/../inc/auth.php';
|
require_once __DIR__ . '/../inc/auth.php';
|
||||||
require_once __DIR__ . '/../inc/assets.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_once __DIR__ . '/../inc/announcements.php';
|
||||||
|
|
||||||
require_auth();
|
require_auth();
|
||||||
@@ -102,38 +104,10 @@ $announcements = get_all_announcements();
|
|||||||
?>
|
?>
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<?php render_admin_head('Announcements'); ?>
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Pub Management Dashboard | The Old Fitz</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,600;1,9..144,400&family=Karla:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="../styles.css<?= asset_version('styles.css') ?>">
|
|
||||||
</head>
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<nav class="admin-nav">
|
<?php render_admin_nav($user, 'announcements'); ?>
|
||||||
<div class="container admin-nav-inner">
|
|
||||||
<a href="index.php" class="admin-brand">
|
|
||||||
<img src="../assets/knot.png" alt="" class="knot">
|
|
||||||
<span>The Old Fitz Admin</span>
|
|
||||||
</a>
|
|
||||||
<div class="admin-nav-links">
|
|
||||||
<a href="index.php" class="active">Announcements</a>
|
|
||||||
<a href="menu.php">Menu</a>
|
|
||||||
<?php if (($user['role'] ?? '') === 'admin'): ?>
|
|
||||||
<a href="users.php">Users</a>
|
|
||||||
<?php endif; ?>
|
|
||||||
<a href="hours.php">Hours</a>
|
|
||||||
</div>
|
|
||||||
<div class="admin-user">
|
|
||||||
<a href="../" target="_blank" rel="noopener">View Public Site ↗</a>
|
|
||||||
<a href="account.php" class="admin-account-link" title="My Account"><?= htmlspecialchars($user['username']) ?></a>
|
|
||||||
<a href="logout.php">Sign Out</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main class="dashboard-layout">
|
<main class="dashboard-layout">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -194,21 +168,21 @@ $announcements = get_all_announcements();
|
|||||||
</div>
|
</div>
|
||||||
<div data-label="Headline">
|
<div data-label="Headline">
|
||||||
<strong><?= htmlspecialchars($ann['headline']) ?></strong>
|
<strong><?= htmlspecialchars($ann['headline']) ?></strong>
|
||||||
<div class="muted" style="font-size: 0.82rem; margin-top: 0.2rem; max-width: 32ch; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
|
<div class="muted ann-summary">
|
||||||
<?= htmlspecialchars($ann['message']) ?>
|
<?= htmlspecialchars($ann['message']) ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div data-label="Type">
|
<div data-label="Type">
|
||||||
<span class="eyebrow"><?= htmlspecialchars($ann['type']) ?></span>
|
<span class="eyebrow"><?= htmlspecialchars($ann['type']) ?></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="font-mono" data-label="Schedule" style="font-size: 0.8rem; color: var(--ink-muted);">
|
<div class="font-mono ann-schedule" data-label="Schedule">
|
||||||
<?php if (!empty($ann['start_date']) || !empty($ann['end_date'])): ?>
|
<?php if (!empty($ann['start_date']) || !empty($ann['end_date'])): ?>
|
||||||
<?= htmlspecialchars($ann['start_date'] ?: 'Now') ?> → <?= htmlspecialchars($ann['end_date'] ?: 'Ongoing') ?>
|
<?= htmlspecialchars($ann['start_date'] ?: 'Now') ?> → <?= htmlspecialchars($ann['end_date'] ?: 'Ongoing') ?>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
Always (while on)
|
Always (while on)
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<div data-label="Placements" style="font-size: 0.82rem; color: var(--ink-muted);">
|
<div class="ann-placements" data-label="Placements">
|
||||||
<?php
|
<?php
|
||||||
$locs = [];
|
$locs = [];
|
||||||
if (!empty($ann['show_banner'])) $locs[] = 'Top Banner';
|
if (!empty($ann['show_banner'])) $locs[] = 'Top Banner';
|
||||||
@@ -221,7 +195,7 @@ $announcements = get_all_announcements();
|
|||||||
<div class="action-stack">
|
<div class="action-stack">
|
||||||
<a href="index.php?edit=<?= (int)$ann['id'] ?>" class="btn btn-outline btn-sm">Edit</a>
|
<a href="index.php?edit=<?= (int)$ann['id'] ?>" class="btn btn-outline btn-sm">Edit</a>
|
||||||
|
|
||||||
<form method="POST" action="index.php" style="display: inline;" class="js-confirm-submit" data-confirm="Are you sure you want to delete this announcement?">
|
<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="action" value="delete_announcement">
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||||
<input type="hidden" name="announcement_id" value="<?= (int)$ann['id'] ?>">
|
<input type="hidden" name="announcement_id" value="<?= (int)$ann['id'] ?>">
|
||||||
@@ -239,11 +213,10 @@ $announcements = get_all_announcements();
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Modal Backdrop: Announcement Editor (Create / Edit) — server-rendered
|
<!-- Modal: Announcement Editor (Create / Edit). The server marks it
|
||||||
open/closed via the is-open class, since this page has no JS file;
|
data-open-on-load for ?new / ?edit URLs and index.js opens it with
|
||||||
the underlying save is still a plain form POST + reload, unchanged. -->
|
showModal(); saving is still a plain form POST + reload. -->
|
||||||
<div class="admin-modal-backdrop<?= ($isCreatingNew || $editItem) ? ' is-open' : '' ?>" role="dialog" aria-modal="true" aria-labelledby="modal-ann-title">
|
<dialog class="admin-modal" id="modal-ann" aria-labelledby="modal-ann-title"<?= ($isCreatingNew || $editItem) ? ' data-open-on-load' : '' ?>>
|
||||||
<div class="admin-modal">
|
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 id="modal-ann-title"><?= $editItem ? '✏️ Edit Announcement' : '✨ New Announcement' ?></h2>
|
<h2 id="modal-ann-title"><?= $editItem ? '✏️ Edit Announcement' : '✨ New Announcement' ?></h2>
|
||||||
<a href="index.php" class="modal-close" aria-label="Close dialog">×</a>
|
<a href="index.php" class="modal-close" aria-label="Close dialog">×</a>
|
||||||
@@ -287,14 +260,14 @@ $announcements = get_all_announcements();
|
|||||||
<label for="start_date">Start Date (Optional Auto-Schedule)</label>
|
<label for="start_date">Start Date (Optional Auto-Schedule)</label>
|
||||||
<input type="date" id="start_date" name="start_date" class="form-control"
|
<input type="date" id="start_date" name="start_date" class="form-control"
|
||||||
value="<?= htmlspecialchars($editItem['start_date'] ?? '') ?>">
|
value="<?= htmlspecialchars($editItem['start_date'] ?? '') ?>">
|
||||||
<span class="muted font-mono" style="font-size: 0.78rem;">Leave blank to show immediately while enabled.</span>
|
<span class="muted font-mono ann-field-hint">Leave blank to show immediately while enabled.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="end_date">End Date (Optional Expiration)</label>
|
<label for="end_date">End Date (Optional Expiration)</label>
|
||||||
<input type="date" id="end_date" name="end_date" class="form-control"
|
<input type="date" id="end_date" name="end_date" class="form-control"
|
||||||
value="<?= htmlspecialchars($editItem['end_date'] ?? '') ?>">
|
value="<?= htmlspecialchars($editItem['end_date'] ?? '') ?>">
|
||||||
<span class="muted font-mono" style="font-size: 0.78rem;">Auto-hides after 11:59 PM on this date.</span>
|
<span class="muted font-mono ann-field-hint">Auto-hides after 11:59 PM on this date.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group full-width">
|
<div class="form-group full-width">
|
||||||
@@ -317,8 +290,7 @@ $announcements = get_all_announcements();
|
|||||||
<a href="index.php" class="btn btn-outline">Cancel</a>
|
<a href="index.php" class="btn btn-outline">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</dialog>
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="index.js" defer></script>
|
<script src="index.js" defer></script>
|
||||||
|
|
||||||
|
|||||||
+2
-9
@@ -2,6 +2,7 @@
|
|||||||
require_once __DIR__ . '/../inc/auth.php';
|
require_once __DIR__ . '/../inc/auth.php';
|
||||||
require_once __DIR__ . '/../inc/login-throttle.php';
|
require_once __DIR__ . '/../inc/login-throttle.php';
|
||||||
require_once __DIR__ . '/../inc/assets.php';
|
require_once __DIR__ . '/../inc/assets.php';
|
||||||
|
require_once __DIR__ . '/../inc/admin-head.php';
|
||||||
|
|
||||||
$error = '';
|
$error = '';
|
||||||
|
|
||||||
@@ -27,15 +28,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
?>
|
?>
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<?php render_admin_head('Sign In'); ?>
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Admin Login | The Old Fitz Public House</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:wght@600&family=Karla:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="../styles.css<?= asset_version('styles.css') ?>">
|
|
||||||
</head>
|
|
||||||
<body class="admin-login-body">
|
<body class="admin-login-body">
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<div class="login-header">
|
<div class="login-header">
|
||||||
|
|||||||
+15
-9
@@ -5,8 +5,8 @@
|
|||||||
const csrfToken = document.body.dataset.csrfToken;
|
const csrfToken = document.body.dataset.csrfToken;
|
||||||
|
|
||||||
// Item Modal helpers
|
// Item Modal helpers
|
||||||
const itemModal = document.getElementById('modal-item-backdrop');
|
const itemModal = document.getElementById('modal-item');
|
||||||
const catModal = document.getElementById('modal-cat-backdrop');
|
const catModal = document.getElementById('modal-cat');
|
||||||
|
|
||||||
function openItemModal(itemData = null) {
|
function openItemModal(itemData = null) {
|
||||||
const title = document.getElementById('modal-item-title');
|
const title = document.getElementById('modal-item-title');
|
||||||
@@ -37,12 +37,12 @@ function openItemModal(itemData = null) {
|
|||||||
availField.checked = true;
|
availField.checked = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
itemModal.classList.add('is-open');
|
itemModal.showModal();
|
||||||
nameField.focus();
|
nameField.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeItemModal() {
|
function closeItemModal() {
|
||||||
itemModal.classList.remove('is-open');
|
itemModal.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCategoryModal(catData = null) {
|
function openCategoryModal(catData = null) {
|
||||||
@@ -63,18 +63,24 @@ function openCategoryModal(catData = null) {
|
|||||||
descField.value = '';
|
descField.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
catModal.classList.add('is-open');
|
catModal.showModal();
|
||||||
nameField.focus();
|
nameField.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeCategoryModal() {
|
function closeCategoryModal() {
|
||||||
catModal.classList.remove('is-open');
|
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) {
|
function escapeHtml(text) {
|
||||||
const div = document.createElement('div');
|
return String(text ?? '')
|
||||||
div.textContent = text ?? '';
|
.replaceAll('&', '&')
|
||||||
return div.innerHTML;
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postForm(formData) {
|
async function postForm(formData) {
|
||||||
|
|||||||
+10
-40
@@ -3,6 +3,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
require_once __DIR__ . '/../inc/auth.php';
|
require_once __DIR__ . '/../inc/auth.php';
|
||||||
require_once __DIR__ . '/../inc/assets.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_once __DIR__ . '/../inc/menu.php';
|
||||||
|
|
||||||
require_auth();
|
require_auth();
|
||||||
@@ -180,39 +182,11 @@ $menuEnabled = is_menu_enabled();
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<?php render_admin_head('Menu'); ?>
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Menu Management | The Old Fitz Admin</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,600;1,9..144,400&family=Karla:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="../styles.css<?= asset_version('styles.css') ?>">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body data-csrf-token="<?= htmlspecialchars(get_csrf_token(), ENT_QUOTES, 'UTF-8') ?>">
|
<body data-csrf-token="<?= htmlspecialchars(get_csrf_token(), ENT_QUOTES, 'UTF-8') ?>">
|
||||||
|
|
||||||
<nav class="admin-nav">
|
<?php render_admin_nav($user, 'menu'); ?>
|
||||||
<div class="container admin-nav-inner">
|
|
||||||
<a href="index.php" class="admin-brand">
|
|
||||||
<img src="../assets/knot.png" alt="" class="knot">
|
|
||||||
<span>The Old Fitz Admin</span>
|
|
||||||
</a>
|
|
||||||
<div class="admin-nav-links">
|
|
||||||
<a href="index.php">Announcements</a>
|
|
||||||
<a href="menu.php" class="active">Menu</a>
|
|
||||||
<?php if (($user['role'] ?? '') === 'admin'): ?>
|
|
||||||
<a href="users.php">Users</a>
|
|
||||||
<?php endif; ?>
|
|
||||||
<a href="hours.php">Hours</a>
|
|
||||||
</div>
|
|
||||||
<div class="admin-user">
|
|
||||||
<a href="../" target="_blank" rel="noopener">View Public Site ↗</a>
|
|
||||||
<a href="account.php" class="admin-account-link" title="My Account"><?= htmlspecialchars($user['username']) ?></a>
|
|
||||||
<a href="logout.php">Sign Out</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main class="dashboard-layout">
|
<main class="dashboard-layout">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -328,9 +302,8 @@ $menuEnabled = is_menu_enabled();
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Modal Backdrop: Menu Item -->
|
<!-- Modal: Menu Item -->
|
||||||
<div class="admin-modal-backdrop" id="modal-item-backdrop" role="dialog" aria-modal="true" aria-labelledby="modal-item-title">
|
<dialog class="admin-modal" id="modal-item" aria-labelledby="modal-item-title">
|
||||||
<div class="admin-modal">
|
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 id="modal-item-title">✨ Add Menu Item</h2>
|
<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>
|
<button type="button" class="modal-close" aria-label="Close dialog" id="btn-close-item-modal">×</button>
|
||||||
@@ -383,12 +356,10 @@ $menuEnabled = is_menu_enabled();
|
|||||||
<button type="button" class="btn btn-outline" id="btn-cancel-item">Cancel</button>
|
<button type="button" class="btn btn-outline" id="btn-cancel-item">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</dialog>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Modal Backdrop: Category -->
|
<!-- Modal: Category -->
|
||||||
<div class="admin-modal-backdrop" id="modal-cat-backdrop" role="dialog" aria-modal="true" aria-labelledby="modal-cat-title">
|
<dialog class="admin-modal" id="modal-cat" aria-labelledby="modal-cat-title">
|
||||||
<div class="admin-modal">
|
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 id="modal-cat-title">✨ Add Category</h2>
|
<h2 id="modal-cat-title">✨ Add Category</h2>
|
||||||
<button type="button" class="modal-close" aria-label="Close dialog" id="btn-close-cat-modal">×</button>
|
<button type="button" class="modal-close" aria-label="Close dialog" id="btn-close-cat-modal">×</button>
|
||||||
@@ -415,8 +386,7 @@ $menuEnabled = is_menu_enabled();
|
|||||||
<button type="button" class="btn btn-outline" id="btn-cancel-cat">Cancel</button>
|
<button type="button" class="btn btn-outline" id="btn-cancel-cat">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</dialog>
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="menu.js" defer></script>
|
<script src="menu.js" defer></script>
|
||||||
|
|
||||||
|
|||||||
+19
-15
@@ -5,14 +5,20 @@
|
|||||||
const csrfToken = document.body.dataset.csrfToken;
|
const csrfToken = document.body.dataset.csrfToken;
|
||||||
const currentUserId = document.body.dataset.currentUserId;
|
const currentUserId = document.body.dataset.currentUserId;
|
||||||
|
|
||||||
const userModal = document.getElementById('modal-user-backdrop');
|
const userModal = document.getElementById('modal-user');
|
||||||
const passwordModal = document.getElementById('modal-password-backdrop');
|
const passwordModal = document.getElementById('modal-password');
|
||||||
const editModal = document.getElementById('modal-edit-backdrop');
|
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) {
|
function escapeHtml(text) {
|
||||||
const div = document.createElement('div');
|
return String(text ?? '')
|
||||||
div.textContent = text ?? '';
|
.replaceAll('&', '&')
|
||||||
return div.innerHTML;
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postForm(formData) {
|
async function postForm(formData) {
|
||||||
@@ -54,16 +60,14 @@ function openUserModal() {
|
|||||||
const form = document.getElementById('form-user');
|
const form = document.getElementById('form-user');
|
||||||
form.reset();
|
form.reset();
|
||||||
resetPasswordVisibility(form);
|
resetPasswordVisibility(form);
|
||||||
userModal.classList.add('is-open');
|
userModal.showModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Safari ignores autocomplete="off" on fields labeled "Username" and offers
|
// Native <dialog>: close() hides it (not rendered, so its fields can't keep
|
||||||
// saved logins whenever one has focus. The modals used to auto-focus that
|
// focus or trigger autofill) and returns focus to whatever opened it.
|
||||||
// field, and a closed modal left it focused, so the prompt kept popping up.
|
// Esc closes it too, with no extra code.
|
||||||
// Now nothing is auto-focused, and closing a modal drops focus.
|
|
||||||
function closeModal(modal) {
|
function closeModal(modal) {
|
||||||
modal.classList.remove('is-open');
|
modal.close();
|
||||||
if (modal.contains(document.activeElement)) document.activeElement.blur();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeUserModal() {
|
function closeUserModal() {
|
||||||
@@ -84,7 +88,7 @@ function openPasswordModal(userId, username) {
|
|||||||
|
|
||||||
document.getElementById('password-current-group').style.display = isSelf ? '' : 'none';
|
document.getElementById('password-current-group').style.display = isSelf ? '' : 'none';
|
||||||
|
|
||||||
passwordModal.classList.add('is-open');
|
passwordModal.showModal();
|
||||||
(isSelf ? document.getElementById('password-field-current') : document.getElementById('password-field-new')).focus();
|
(isSelf ? document.getElementById('password-field-current') : document.getElementById('password-field-new')).focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +109,7 @@ function openEditModal(userId, username, role) {
|
|||||||
roleSelect.disabled = isSelf;
|
roleSelect.disabled = isSelf;
|
||||||
document.getElementById('edit-role-self-hint').hidden = !isSelf;
|
document.getElementById('edit-role-self-hint').hidden = !isSelf;
|
||||||
|
|
||||||
editModal.classList.add('is-open');
|
editModal.showModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeEditModal() {
|
function closeEditModal() {
|
||||||
|
|||||||
+13
-43
@@ -3,6 +3,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
require_once __DIR__ . '/../inc/auth.php';
|
require_once __DIR__ . '/../inc/auth.php';
|
||||||
require_once __DIR__ . '/../inc/assets.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';
|
require_once __DIR__ . '/../inc/users.php';
|
||||||
|
|
||||||
// User management is admin-only. Other pages don't check roles yet —
|
// User management is admin-only. Other pages don't check roles yet —
|
||||||
@@ -190,37 +192,11 @@ $users = get_all_users();
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<?php render_admin_head('Users'); ?>
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>User Management | The Old Fitz Admin</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,600;1,9..144,400&family=Karla:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="../styles.css<?= asset_version('styles.css') ?>">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body data-csrf-token="<?= htmlspecialchars(get_csrf_token(), ENT_QUOTES, 'UTF-8') ?>" data-current-user-id="<?= (int) $user['id'] ?>">
|
<body data-csrf-token="<?= htmlspecialchars(get_csrf_token(), ENT_QUOTES, 'UTF-8') ?>" data-current-user-id="<?= (int) $user['id'] ?>">
|
||||||
|
|
||||||
<nav class="admin-nav">
|
<?php render_admin_nav($user, 'users'); ?>
|
||||||
<div class="container admin-nav-inner">
|
|
||||||
<a href="index.php" class="admin-brand">
|
|
||||||
<img src="../assets/knot.png" alt="" class="knot">
|
|
||||||
<span>The Old Fitz Admin</span>
|
|
||||||
</a>
|
|
||||||
<div class="admin-nav-links">
|
|
||||||
<a href="index.php">Announcements</a>
|
|
||||||
<a href="menu.php">Menu</a>
|
|
||||||
<a href="users.php" class="active">Users</a>
|
|
||||||
<a href="hours.php">Hours</a>
|
|
||||||
</div>
|
|
||||||
<div class="admin-user">
|
|
||||||
<a href="../" target="_blank" rel="noopener">View Public Site ↗</a>
|
|
||||||
<a href="account.php" class="admin-account-link" title="My Account"><?= htmlspecialchars($user['username']) ?></a>
|
|
||||||
<a href="logout.php">Sign Out</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main class="dashboard-layout">
|
<main class="dashboard-layout">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -302,9 +278,8 @@ $users = get_all_users();
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Modal Backdrop: Add User -->
|
<!-- Modal: Add User -->
|
||||||
<div class="admin-modal-backdrop" id="modal-user-backdrop" role="dialog" aria-modal="true" aria-labelledby="modal-user-title">
|
<dialog class="admin-modal" id="modal-user" aria-labelledby="modal-user-title">
|
||||||
<div class="admin-modal">
|
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 id="modal-user-title">✨ Add User</h2>
|
<h2 id="modal-user-title">✨ Add User</h2>
|
||||||
<button type="button" class="modal-close" aria-label="Close dialog" id="btn-close-user-modal">×</button>
|
<button type="button" class="modal-close" aria-label="Close dialog" id="btn-close-user-modal">×</button>
|
||||||
@@ -361,12 +336,10 @@ $users = get_all_users();
|
|||||||
<button type="button" class="btn btn-outline" id="btn-cancel-user">Cancel</button>
|
<button type="button" class="btn btn-outline" id="btn-cancel-user">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</dialog>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Modal Backdrop: Edit User -->
|
<!-- Modal: Edit User -->
|
||||||
<div class="admin-modal-backdrop" id="modal-edit-backdrop" role="dialog" aria-modal="true" aria-labelledby="modal-edit-title">
|
<dialog class="admin-modal" id="modal-edit" aria-labelledby="modal-edit-title">
|
||||||
<div class="admin-modal">
|
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 id="modal-edit-title">✏️ Edit User</h2>
|
<h2 id="modal-edit-title">✏️ Edit User</h2>
|
||||||
<button type="button" class="modal-close" aria-label="Close dialog" id="btn-close-edit-modal">×</button>
|
<button type="button" class="modal-close" aria-label="Close dialog" id="btn-close-edit-modal">×</button>
|
||||||
@@ -398,12 +371,10 @@ $users = get_all_users();
|
|||||||
<button type="button" class="btn btn-outline" id="btn-cancel-edit">Cancel</button>
|
<button type="button" class="btn btn-outline" id="btn-cancel-edit">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</dialog>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Modal Backdrop: Change Password -->
|
<!-- Modal: Change Password -->
|
||||||
<div class="admin-modal-backdrop" id="modal-password-backdrop" role="dialog" aria-modal="true" aria-labelledby="modal-password-title">
|
<dialog class="admin-modal" id="modal-password" aria-labelledby="modal-password-title">
|
||||||
<div class="admin-modal">
|
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 id="modal-password-title">🔑 Change Password</h2>
|
<h2 id="modal-password-title">🔑 Change Password</h2>
|
||||||
<button type="button" class="modal-close" aria-label="Close dialog" id="btn-close-password-modal">×</button>
|
<button type="button" class="modal-close" aria-label="Close dialog" id="btn-close-password-modal">×</button>
|
||||||
@@ -459,8 +430,7 @@ $users = get_all_users();
|
|||||||
<button type="button" class="btn btn-outline" id="btn-cancel-password">Cancel</button>
|
<button type="button" class="btn btn-outline" id="btn-cancel-password">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</dialog>
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="users.js" defer></script>
|
<script src="users.js" defer></script>
|
||||||
|
|
||||||
|
|||||||
+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.5-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.5 + 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
|
||||||
|
}
|
||||||
+2
-15
@@ -3,21 +3,8 @@
|
|||||||
* Auth & session helper
|
* Auth & session helper
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
require_once __DIR__ . '/security-headers.php';
|
||||||
* Whether the visitor reached us over HTTPS. TLS ends at the reverse proxy
|
send_security_headers();
|
||||||
* (NPM or Caddy), so PHP itself only ever sees plain HTTP — trust the
|
|
||||||
* proxy's X-Forwarded-Proto, but only from a private/loopback address so a
|
|
||||||
* public client can't claim it.
|
|
||||||
*/
|
|
||||||
function request_is_https(): bool {
|
|
||||||
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
$remote = $_SERVER['REMOTE_ADDR'] ?? '';
|
|
||||||
$fromProxy = filter_var($remote, FILTER_VALIDATE_IP)
|
|
||||||
&& !filter_var($remote, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
|
|
||||||
return $fromProxy && strtolower($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
// Configure secure session cookie defaults. 'secure' follows the
|
// Configure secure session cookie defaults. 'secure' follows the
|
||||||
|
|||||||
+5
-2
@@ -11,12 +11,15 @@ function get_db(): PDO {
|
|||||||
return $pdo;
|
return $pdo;
|
||||||
}
|
}
|
||||||
|
|
||||||
$dbDir = __DIR__ . '/../db';
|
// 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)) {
|
if (!is_dir($dbDir)) {
|
||||||
mkdir($dbDir, 0755, true);
|
mkdir($dbDir, 0755, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
$dbPath = $dbDir . '/fitz.sqlite';
|
|
||||||
$isNew = !file_exists($dbPath);
|
$isNew = !file_exists($dbPath);
|
||||||
|
|
||||||
$pdo = new PDO('sqlite:' . $dbPath, null, null, [
|
$pdo = new PDO('sqlite:' . $dbPath, null, null, [
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ function fritz_google_reviews(): array {
|
|||||||
return $fallback;
|
return $fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
$cacheFile = __DIR__ . '/../cache/google-reviews.json';
|
// 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)) {
|
if (is_file($cacheFile)) {
|
||||||
$cached = json_decode((string) file_get_contents($cacheFile), true);
|
$cached = json_decode((string) file_get_contents($cacheFile), true);
|
||||||
@@ -33,7 +37,6 @@ function fritz_google_reviews(): array {
|
|||||||
$fresh = fritz_fetch_google_reviews($fallback);
|
$fresh = fritz_fetch_google_reviews($fallback);
|
||||||
$fresh['fetched_at'] = time();
|
$fresh['fetched_at'] = time();
|
||||||
|
|
||||||
$cacheDir = dirname($cacheFile);
|
|
||||||
if (!is_dir($cacheDir)) {
|
if (!is_dir($cacheDir)) {
|
||||||
@mkdir($cacheDir, 0775, true);
|
@mkdir($cacheDir, 0775, true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
require __DIR__ . '/config.php';
|
require __DIR__ . '/inc/load-config.php';
|
||||||
require __DIR__ . '/inc/assets.php';
|
require __DIR__ . '/inc/assets.php';
|
||||||
|
require __DIR__ . '/inc/security-headers.php';
|
||||||
|
send_security_headers();
|
||||||
require __DIR__ . '/inc/google-reviews.php';
|
require __DIR__ . '/inc/google-reviews.php';
|
||||||
require __DIR__ . '/inc/announcements.php';
|
require __DIR__ . '/inc/announcements.php';
|
||||||
require __DIR__ . '/inc/hours.php';
|
require __DIR__ . '/inc/hours.php';
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
require __DIR__ . '/config.php';
|
require __DIR__ . '/inc/load-config.php';
|
||||||
require __DIR__ . '/inc/assets.php';
|
require __DIR__ . '/inc/assets.php';
|
||||||
|
require __DIR__ . '/inc/security-headers.php';
|
||||||
|
send_security_headers();
|
||||||
require __DIR__ . '/inc/announcements.php';
|
require __DIR__ . '/inc/announcements.php';
|
||||||
require __DIR__ . '/inc/menu.php';
|
require __DIR__ . '/inc/menu.php';
|
||||||
require __DIR__ . '/inc/hours.php';
|
require __DIR__ . '/inc/hours.php';
|
||||||
@@ -94,24 +96,24 @@ $siteUrl = rtrim(SITE_URL, '/');
|
|||||||
<main id="main">
|
<main id="main">
|
||||||
|
|
||||||
<section class="menu-page-header">
|
<section class="menu-page-header">
|
||||||
<div class="container" style="text-align: center;">
|
<div class="container menu-hero">
|
||||||
<p class="eyebrow">The Old Fitz Public House</p>
|
<p class="eyebrow">The Old Fitz Public House</p>
|
||||||
<h1 style="font-size: clamp(2.4rem, 5vw, 3.8rem); color: var(--gold-bright); margin-top: 0.5rem;">Pub Menu</h1>
|
<h1 class="menu-hero-title">Pub Menu</h1>
|
||||||
<div class="rule-row" style="margin-top: 1.5rem; margin-bottom: 1.5rem;">
|
<div class="rule-row menu-hero-rule">
|
||||||
<span class="line"></span>
|
<span class="line"></span>
|
||||||
<img class="knot" src="./assets/knot.png" alt="">
|
<img class="knot" src="./assets/knot.png" alt="">
|
||||||
<span class="line"></span>
|
<span class="line"></span>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted" style="max-width: 52ch; margin: 0 auto; font-size: 1.05rem;">
|
<p class="muted menu-hero-intro">
|
||||||
Traditional Irish staples, shareable plates, and comforting pub classics cooked fresh. Kitchen serves until 9:00 PM.
|
Traditional Irish staples, shareable plates, and comforting pub classics cooked fresh. Kitchen serves until 9:00 PM.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="menu-page-content" style="padding-top: 0;">
|
<section class="menu-page-content">
|
||||||
<div class="container" style="max-width: 62rem;">
|
<div class="container menu-page-container">
|
||||||
<?php if ($menuEnabled && !empty($menuCategories)): ?>
|
<?php if ($menuEnabled && !empty($menuCategories)): ?>
|
||||||
<div class="menu-grid" style="margin-top: 0;">
|
<div class="menu-grid menu-grid-flush">
|
||||||
<?php foreach ($menuCategories as $category): ?>
|
<?php foreach ($menuCategories as $category): ?>
|
||||||
<?php if (!empty($category['items'])): ?>
|
<?php if (!empty($category['items'])): ?>
|
||||||
<div class="menu-category">
|
<div class="menu-category">
|
||||||
@@ -144,17 +146,17 @@ $siteUrl = rtrim(SITE_URL, '/');
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="panel" style="text-align: center; padding: 3rem 1.5rem;">
|
<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>
|
<p class="muted">Our kitchen menu is currently being refreshed. Please check our Facebook page for today's specials!</p>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<!-- Allergen / Food Safety Disclaimer from the original print menu -->
|
<!-- Allergen / Food Safety Disclaimer from the original print menu -->
|
||||||
<div class="menu-disclaimer" style="margin-top: 3.5rem; text-align: center; padding-top: 2rem; border-top: 1px solid var(--rule);">
|
<div class="menu-disclaimer">
|
||||||
<p class="muted" style="font-size: 0.8rem; line-height: 1.6; max-width: 68ch; margin: 0 auto;">
|
<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.
|
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>
|
</p>
|
||||||
<div class="hero-ctas" style="margin-top: 2rem; justify-content: center;">
|
<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-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>
|
<a class="btn btn-outline" href="mailto:info@theoldfitzpub.com">Ask a Kitchen Question</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently --kill-others-on-fail \"npm:dev:php\" \"npm:dev:sass\" \"npm:dev:browser\"",
|
"dev": "concurrently --kill-others-on-fail \"npm:dev:php\" \"npm:dev:sass\" \"npm:dev:browser\"",
|
||||||
"dev:php": "php -S localhost:8000",
|
"dev:php": "php -S localhost:8000 scripts/dev-router.php",
|
||||||
"dev:sass": "sass --watch --source-map src/scss/styles.scss:styles.css",
|
"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",
|
"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",
|
"build:sass": "sass --no-source-map src/scss/styles.scss:styles.css",
|
||||||
|
|||||||
+27
-4
@@ -6,9 +6,14 @@
|
|||||||
*
|
*
|
||||||
* This is an allowlist by design: only the files and folders listed below
|
* This is an allowlist by design: only the files and folders listed below
|
||||||
* are copied. Dev-only stuff (node_modules/, src/, package*.json, .git/,
|
* are copied. Dev-only stuff (node_modules/, src/, package*.json, .git/,
|
||||||
* .claude/, docker-compose.yaml, dev.sh, .hintrc, config.example.php, the
|
* .claude/, docker-compose.yaml, dev.sh, .hintrc, config.php,
|
||||||
* live db/fitz.sqlite, ...) never ends up in dist/ — including anything
|
* config.example.php, the live db/fitz.sqlite, ...) never ends up in
|
||||||
* added later, since it has to be added here explicitly first.
|
* 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
|
* Run with: npm run dist
|
||||||
*/
|
*/
|
||||||
@@ -25,7 +30,6 @@ const FILES = [
|
|||||||
'main.js',
|
'main.js',
|
||||||
'styles.css',
|
'styles.css',
|
||||||
'robots.txt',
|
'robots.txt',
|
||||||
'config.php', // holds the live FB_PAGE_ACCESS_TOKEN — see README before sharing dist/ anywhere but the server
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const DIRS = ['admin', 'assets', 'bin', 'inc'];
|
const DIRS = ['admin', 'assets', 'bin', 'inc'];
|
||||||
@@ -74,6 +78,25 @@ if (fs.existsSync(dbHtaccess)) {
|
|||||||
missing.push('db/.htaccess');
|
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) {
|
if (missing.length) {
|
||||||
console.warn('Warning: expected but not found, skipped:\n ' + missing.join('\n '));
|
console.warn('Warning: expected but not found, skipped:\n ' + missing.join('\n '));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1,49 +1,62 @@
|
|||||||
/* ---------- admin modal dialogs ---------- */
|
/* ---------- admin modal dialogs ---------- */
|
||||||
.admin-modal-backdrop {
|
/* Native <dialog> modals. The browser handles the overlay (::backdrop),
|
||||||
position: fixed;
|
focus trapping, inertness of the page behind, and Esc. A closed dialog
|
||||||
inset: 0;
|
is display: none, so its fields can't hold focus or trigger autofill.
|
||||||
background: rgba(15, 12, 10, 0.8);
|
Open/close fades rely on @starting-style for the entry and
|
||||||
backdrop-filter: blur(0.25rem);
|
allow-discrete to keep display and overlay alive through the exit. */
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
padding: 1.5rem;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
// Closed modals must be visibility: hidden, not just transparent —
|
|
||||||
// otherwise their fields stay focusable and Safari offers saved logins
|
|
||||||
// for an invisible Username field. The visibility switch waits for the
|
|
||||||
// fade-out to finish so the close animation still plays.
|
|
||||||
visibility: hidden;
|
|
||||||
transition: opacity .2s ease, visibility 0s linear .2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-modal-backdrop.is-open {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
visibility: visible;
|
|
||||||
transition: opacity .2s ease, visibility 0s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-modal {
|
.admin-modal {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
color: inherit;
|
||||||
border: 1px solid var(--rule);
|
border: 1px solid var(--rule);
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
width: 100%;
|
width: calc(100% - 3rem);
|
||||||
max-width: 36rem;
|
max-width: 36rem;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
|
margin: auto;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
box-shadow: 0 1.5rem 3rem rgba(0, 0, 0, 0.6);
|
box-shadow: 0 1.5rem 3rem rgba(0, 0, 0, 0.6);
|
||||||
|
opacity: 0;
|
||||||
transform: translateY(0.75rem);
|
transform: translateY(0.75rem);
|
||||||
transition: transform .2s ease;
|
transition:
|
||||||
|
opacity .2s ease,
|
||||||
|
transform .2s ease,
|
||||||
|
overlay .2s ease allow-discrete,
|
||||||
|
display .2s ease allow-discrete;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-modal-backdrop.is-open .admin-modal {
|
.admin-modal[open] {
|
||||||
|
opacity: 1;
|
||||||
transform: translateY(0);
|
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 {
|
.modal-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@@ -46,6 +46,30 @@ textarea.form-control {
|
|||||||
line-height: 1.5;
|
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,
|
.error-msg,
|
||||||
.alert-error {
|
.alert-error {
|
||||||
background: var(--danger-bg);
|
background: var(--danger-bg);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -141,3 +141,12 @@
|
|||||||
grid-template-columns: 1fr;
|
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;
|
||||||
|
}
|
||||||
@@ -94,5 +94,61 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.menu-page-content {
|
.menu-page-content {
|
||||||
|
padding-top: 0;
|
||||||
padding-bottom: 5.5rem;
|
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;
|
||||||
|
}
|
||||||
@@ -31,4 +31,5 @@
|
|||||||
@use "pages/admin-login";
|
@use "pages/admin-login";
|
||||||
@use "pages/admin-menu";
|
@use "pages/admin-menu";
|
||||||
@use "pages/admin-hours";
|
@use "pages/admin-hours";
|
||||||
|
@use "pages/admin-announcements";
|
||||||
@use "pages/admin-account";
|
@use "pages/admin-account";
|
||||||
+148
-26
@@ -785,47 +785,54 @@ button.status-badge:disabled {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* ---------- admin modal dialogs ---------- */
|
/* ---------- admin modal dialogs ---------- */
|
||||||
.admin-modal-backdrop {
|
/* Native <dialog> modals. The browser handles the overlay (::backdrop),
|
||||||
position: fixed;
|
focus trapping, inertness of the page behind, and Esc. A closed dialog
|
||||||
inset: 0;
|
is display: none, so its fields can't hold focus or trigger autofill.
|
||||||
background: rgba(15, 12, 10, 0.8);
|
Open/close fades rely on @starting-style for the entry and
|
||||||
backdrop-filter: blur(0.25rem);
|
allow-discrete to keep display and overlay alive through the exit. */
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
padding: 1.5rem;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
visibility: hidden;
|
|
||||||
transition: opacity 0.2s ease, visibility 0s linear 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-modal-backdrop.is-open {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
visibility: visible;
|
|
||||||
transition: opacity 0.2s ease, visibility 0s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-modal {
|
.admin-modal {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
color: inherit;
|
||||||
border: 1px solid var(--rule);
|
border: 1px solid var(--rule);
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
width: 100%;
|
width: calc(100% - 3rem);
|
||||||
max-width: 36rem;
|
max-width: 36rem;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
|
margin: auto;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
box-shadow: 0 1.5rem 3rem rgba(0, 0, 0, 0.6);
|
box-shadow: 0 1.5rem 3rem rgba(0, 0, 0, 0.6);
|
||||||
|
opacity: 0;
|
||||||
transform: translateY(0.75rem);
|
transform: translateY(0.75rem);
|
||||||
transition: transform 0.2s ease;
|
transition: opacity 0.2s ease, transform 0.2s ease, overlay 0.2s ease allow-discrete, display 0.2s ease allow-discrete;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-modal-backdrop.is-open .admin-modal {
|
.admin-modal[open] {
|
||||||
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-modal::backdrop {
|
||||||
|
background-color: rgba(15, 12, 10, 0);
|
||||||
|
backdrop-filter: blur(0);
|
||||||
|
transition: background-color 0.2s ease, backdrop-filter 0.2s ease, overlay 0.2s ease allow-discrete, display 0.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 {
|
.modal-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -905,6 +912,26 @@ textarea.form-control {
|
|||||||
line-height: 1.5;
|
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,
|
.error-msg,
|
||||||
.alert-error {
|
.alert-error {
|
||||||
background: var(--danger-bg);
|
background: var(--danger-bg);
|
||||||
@@ -1906,9 +1933,65 @@ footer a.email:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.menu-page-content {
|
.menu-page-content {
|
||||||
|
padding-top: 0;
|
||||||
padding-bottom: 5.5rem;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- admin / management styles ---------- */
|
/* ---------- admin / management styles ---------- */
|
||||||
.admin-login-body {
|
.admin-login-body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -2169,6 +2252,45 @@ footer a.email:hover {
|
|||||||
grid-template-columns: 1fr;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 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;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- admin: my account ---------- */
|
/* ---------- admin: my account ---------- */
|
||||||
/* One panel, fields in the shared two-column .form-grid (which already
|
/* 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
|
stacks below 47.5em). Capped so the fields don't stretch across the
|
||||||
|
|||||||
Reference in new issue
Block a user