Add Sass build pipeline and production deploy script

- Move hand-authored CSS into src/scss/styles.scss, compiled to the
  tracked styles.css via `npm run build` (sass, no source maps).
- Add package.json/package-lock.json with dev tooling: sass, and a
  `dev` script running php + sass --watch + browser-sync together.
- Add scripts/build-dist.js (npm run dist): assembles an allowlisted,
  production-only copy of the site into dist/ (gitignored) for pushing
  to hosting — excludes node_modules, src/, package files, and the
  live db/fitz.sqlite while including config.php.
- .gitignore: ignore node_modules/, styles.css.map, and dist/.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
David DyerandClaude Sonnet 5 committed 2026-09-01 18:21:01 -04:00
1 parent cd4c6c1969
commit 2145094e55
6 files changed
+4822 -106

No files matched your search

+7
View File
@@ -16,3 +16,10 @@ 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/
+2430
View File
File diff suppressed because it is too large. Load diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "theoldfitz",
"version": "1.0.0",
"description": "The Old Fitz Public House website",
"main": "main.js",
"scripts": {
"dev": "concurrently --kill-others-on-fail \"npm:dev:php\" \"npm:dev:sass\" \"npm:dev:browser\"",
"dev:php": "php -S localhost:8000",
"dev:sass": "sass --watch --source-map src/scss/styles.scss:styles.css",
"dev:browser": "browser-sync start --proxy http://localhost:8000 --files \"*.php,admin/**/*.php,inc/**/*.php,main.js,styles.css\" --no-open",
"build": "sass --no-source-map src/scss/styles.scss:styles.css",
"dist": "npm run build && node scripts/build-dist.js"
},
"repository": {
"type": "git",
"url": "https://gitea.home.daviddyer.me/david/the-old-fitz.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"devDependencies": {
"browser-sync": "^3.0.4",
"concurrently": "^10.0.5",
"sass": "^1.103.1"
}
}
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env node
'use strict';
/**
* Assembles a clean, production-only copy of the site into dist/.
*
* This is an allowlist by design: only the files and folders listed below
* are copied. Dev-only stuff (node_modules/, src/, package*.json, .git/,
* .claude/, docker-compose.yaml, dev.sh, .hintrc, config.example.php, the
* live db/fitz.sqlite, ...) never ends up in dist/ — including anything
* added later, since it has to be added here explicitly first.
*
* Run with: npm run dist
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const DIST = path.join(ROOT, 'dist');
const FILES = [
'index.php',
'menu.php',
'main.js',
'styles.css',
'robots.txt',
'config.php', // holds the live FB_PAGE_ACCESS_TOKEN — see README before sharing dist/ anywhere but the server
];
const DIRS = ['admin', 'assets', 'inc'];
function copyDir(src, dest) {
fs.cpSync(src, dest, {
recursive: true,
filter: (source) => path.basename(source) !== '.DS_Store',
});
}
// Fresh start every run so a file removed from the source tree doesn't
// linger in dist/ from a previous build.
fs.rmSync(DIST, { recursive: true, force: true });
fs.mkdirSync(DIST, { recursive: true });
const missing = [];
for (const file of FILES) {
const src = path.join(ROOT, file);
if (!fs.existsSync(src)) {
missing.push(file);
continue;
}
fs.copyFileSync(src, path.join(DIST, file));
}
for (const dir of DIRS) {
const src = path.join(ROOT, dir);
if (!fs.existsSync(src)) {
missing.push(dir + '/');
continue;
}
copyDir(src, path.join(DIST, dir));
}
// db/ ships with just .htaccess (denies direct web access to the sqlite
// file) — never the live fitz.sqlite itself. inc/db.php creates the
// schema on first run if the file isn't there, so shipping the dev
// database would just overwrite whatever is already live on the server.
const dbHtaccess = path.join(ROOT, 'db', '.htaccess');
if (fs.existsSync(dbHtaccess)) {
fs.mkdirSync(path.join(DIST, 'db'), { recursive: true });
fs.copyFileSync(dbHtaccess, path.join(DIST, 'db', '.htaccess'));
} else {
missing.push('db/.htaccess');
}
if (missing.length) {
console.warn('Warning: expected but not found, skipped:\n ' + missing.join('\n '));
}
console.log(`Deploy folder built at ${path.relative(ROOT, DIST)}/`);
+2178
View File
File diff suppressed because it is too large. Load diff
+99 -106
View File
@@ -1,3 +1,4 @@
@charset "UTF-8";
:root {
--ground: #171310;
--ground-2: #0f0c0a;
@@ -11,11 +12,9 @@
--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;
}
@@ -33,7 +32,6 @@
scroll-behavior: auto;
}
}
/* ---------- 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
@@ -117,7 +115,7 @@
border-radius: 0.125rem;
font-family: var(--font-mono);
font-size: 0.85rem;
transition: top .15s ease;
transition: top 0.15s ease;
z-index: 100;
}
@@ -340,7 +338,7 @@
letter-spacing: 0.03em;
color: var(--ink-muted);
padding: 0.4rem 0.2rem;
transition: color .15s ease;
transition: color 0.15s ease;
}
nav.links a:hover {
@@ -387,11 +385,11 @@
display: none;
}
.nav-toggle[aria-expanded="true"] .icon-menu {
.nav-toggle[aria-expanded=true] .icon-menu {
display: none;
}
.nav-toggle[aria-expanded="true"] .icon-close {
.nav-toggle[aria-expanded=true] .icon-close {
display: block;
}
@@ -399,11 +397,9 @@
.nav-toggle {
display: inline-flex;
}
.nav-cta {
display: none;
}
nav.links {
display: none;
position: absolute;
@@ -417,16 +413,13 @@
border-bottom: 1px solid var(--rule);
padding: 0.5rem 0;
}
nav.links.is-open {
display: flex;
}
nav.links a {
padding: 0.9rem 1.5rem;
}
}
/* ---------- hero ---------- */
.hero {
position: relative;
@@ -447,8 +440,7 @@
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));
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.12), transparent 22%, transparent 78%, rgba(0, 0, 0, 0.18));
pointer-events: none;
}
@@ -457,16 +449,14 @@
inset: 0;
z-index: 0;
overflow: hidden;
background-image: url('./assets/brick-texture.png');
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%);
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 {
@@ -545,7 +535,7 @@
letter-spacing: 0.05em;
text-decoration: none;
border-radius: 0.125rem;
transition: transform .15s ease, background .15s ease, border-color .15s ease;
transition: transform 0.15s ease, background 0.15s ease, border-color 0.15s ease;
}
.btn:hover {
@@ -688,7 +678,6 @@
grid-template-columns: 1fr;
}
}
/* hours / visit band */
.visit {
background: var(--ground-2);
@@ -805,7 +794,7 @@
border-radius: 0.125rem;
color: var(--ink-muted);
text-decoration: none;
transition: border-color .15s ease, color .15s ease;
transition: border-color 0.15s ease, color 0.15s ease;
}
.social-row a:hover {
@@ -823,7 +812,6 @@
grid-template-columns: 1fr;
}
}
/* live music band */
.music {
background: linear-gradient(180deg, var(--green-deep), var(--green));
@@ -895,20 +883,16 @@
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;
@@ -1021,12 +1005,11 @@
gap: 2rem;
}
}
/* scroll reveal */
.reveal {
opacity: 0;
transform: translateY(0.875rem);
transition: opacity .6s ease, transform .6s ease;
transition: opacity 0.6s ease, transform 0.6s ease;
}
.reveal.is-visible {
@@ -1041,7 +1024,6 @@
transition: none;
}
}
/* ---------- admin / management styles ---------- */
.admin-login-body {
min-height: 100vh;
@@ -1118,7 +1100,7 @@
border-radius: 0.125rem;
box-sizing: border-box;
outline: none;
transition: border-color .15s ease;
transition: border-color 0.15s ease;
}
.form-control:focus,
@@ -1209,7 +1191,6 @@
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,
@@ -1218,7 +1199,6 @@
gap: 0.5rem 1rem;
}
}
.dashboard-layout {
padding-block: 2.5rem 4rem;
}
@@ -1268,7 +1248,6 @@
grid-template-columns: 1fr;
}
}
.form-actions {
display: flex;
gap: 0.75rem;
@@ -1297,7 +1276,7 @@
font-weight: 500;
}
.checkbox-label input[type="checkbox"] {
.checkbox-label input[type=checkbox] {
width: 1.15rem;
height: 1.15rem;
accent-color: var(--gold);
@@ -1344,7 +1323,7 @@
-webkit-appearance: none;
cursor: pointer;
justify-content: center;
transition: opacity .15s ease;
transition: opacity 0.15s ease;
}
button.status-badge:hover {
@@ -1406,7 +1385,6 @@
white-space: normal;
}
}
.btn-sm {
padding: 0.4rem 0.8rem;
font-size: 0.75rem;
@@ -1457,7 +1435,7 @@
color: var(--ink-muted);
padding: 0.35rem 0.65rem;
border-radius: 0.125rem;
transition: color .15s ease, background .15s ease;
transition: color 0.15s ease, background 0.15s ease;
}
.admin-nav-links a:hover {
@@ -1592,9 +1570,7 @@
.category-panel .panel-head {
display: grid;
grid-template-columns: 1fr auto;
grid-template-areas:
"name actions"
"desc actions";
grid-template-areas: "name actions" "desc actions";
align-items: center;
column-gap: 0.75rem;
}
@@ -1688,39 +1664,90 @@
}
.col-status,
.item-row [data-label="Status"],
.ann-row [data-label="Status"],
.user-row [data-label="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"] {
.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-item {
grid-area: item;
}
.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-price {
grid-area: price;
}
.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-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
@@ -1783,7 +1810,6 @@
.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
@@ -1792,7 +1818,6 @@
.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
@@ -1803,13 +1828,11 @@
.user-row .row-actions {
flex-wrap: nowrap;
}
.item-grid-head,
.ann-grid-head,
.user-grid-head {
display: none;
}
.item-row,
.ann-row,
.user-row {
@@ -1819,14 +1842,12 @@
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 {
@@ -1839,12 +1860,8 @@
color: var(--gold);
margin-bottom: 0.3rem;
}
.item-row {
grid-template-areas:
"status actions"
"item item"
"price price";
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
@@ -1852,26 +1869,14 @@
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";
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";
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
@@ -1883,21 +1888,15 @@
.user-row .row-actions .btn {
width: 100%;
}
.user-row .btn-change-password {
white-space: normal;
}
.category-panel .panel-head {
grid-template-columns: 1fr;
grid-template-areas:
"name"
"desc"
"actions";
grid-template-areas: "name" "desc" "actions";
row-gap: 0.5rem;
}
}
/* ---------- admin modal dialogs ---------- */
.admin-modal-backdrop {
position: fixed;
@@ -1911,7 +1910,7 @@
padding: 1.5rem;
opacity: 0;
pointer-events: none;
transition: opacity .2s ease;
transition: opacity 0.2s ease;
}
.admin-modal-backdrop.is-open {
@@ -1930,7 +1929,7 @@
padding: 2rem;
box-shadow: 0 1.5rem 3rem rgba(0, 0, 0, 0.6);
transform: translateY(0.75rem);
transition: transform .2s ease;
transition: transform 0.2s ease;
}
.admin-modal-backdrop.is-open .admin-modal {
@@ -1961,7 +1960,7 @@
padding: 0.25rem;
line-height: 1;
text-decoration: none;
transition: color .15s ease;
transition: color 0.15s ease;
}
.modal-close:hover {
@@ -2002,7 +2001,7 @@
align-items: center;
justify-content: center;
border-radius: 0.125rem;
transition: color .15s ease;
transition: color 0.15s ease;
}
.password-toggle-btn:hover {
@@ -2089,11 +2088,9 @@
.hours-grid-wrap {
grid-template-columns: 1fr;
}
.hours-grid-head {
display: none;
}
.hours-day-row {
grid-template-columns: 1fr;
row-gap: 0.5rem;
@@ -2102,11 +2099,9 @@
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;
@@ -2117,12 +2112,10 @@
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