Let the owner manage categories, and give the menus a shelf

Categories were baked into config/<site>.site.json, so adding one meant a
rebuild and a redeploy. They are now data: data/sections.json, seeded from the
config the first time, and managed in the admin (add, rename, reorder, remove).
A category's web address is fixed when it is created because that is what a QR
code points at, and a category cannot be removed while it still holds menus.

The landing and the top bar were the ugliest parts. The landing was a column of
hairlines with tiny meta — a broadsheet, and the paper that makes this app worth
looking at was nowhere on it. It is now a shelf: each category is a row whose
right edge is a sliver of that menu's first page, bleeding off the screen, with
the shelf edge in brass and the name in heavy tight Archivo. The bar was a row
of teal pills that would not survive more categories: it now names the menu you
are reading and opens a sheet listing the rest, each with its own paper sliver,
and the current one is the only teal thing on screen. The sheet is a link to the
landing page, so navigating still works without the script.

Also fixes the category manager's own layout, which I shipped unstyled, and the
markup contract the route tests described.
This commit is contained in:
tanshu committed 2026-09-22 22:24:53 +05:30
1 parent 7bf67cc299
commit c31b73d553
25 files changed
+1225 -190

No files matched your search

+4 -2
View File
@@ -11,8 +11,10 @@ One restaurant's customer-facing menu deployment, with its own brand, Sections a
_Avoid_: brand, tenant, deployment, instance
**Section**:
A top-level category of menu content that customers choose between, such as Food, Bar or What's On.
_Avoid_: type, category, tab, folder
A top-level category of menu content that customers choose between, such as Food, Bar or What's On. Sections are content: the owner adds and renames them in the admin.
_Avoid_: type, tab, folder
The admin, and the owner, say **category** where the code and this glossary say Section. The two words mean the same thing; the code keeps Section so the storage layout, routes and manifests stay consistent.
**Menu Set**:
One complete set of Pages published together for a Section, with a label and an optional valid-until date.
+3 -2
View File
@@ -32,7 +32,8 @@ npm run check-site -- https://menu-new.hopsngrains.com
## Layout
```
src/config.ts Site config: brand, theme, Sections, limits
src/config.ts Site config: brand, theme, seed Sections, limits
src/storage/sections.ts Categories: the Site's own list, edited in the admin
src/storage/manifest.ts Manifest type, validation, atomic JSON writes
src/storage/sets.ts The Menu Set store: create, add Pages, publish, restore, delete
src/storage/validate.ts Boot validation: fatal problems and warnings
@@ -43,7 +44,7 @@ src/routes/public.ts Landing, Section, archive and media routes
src/routes/admin.ts Sign in, dashboard, editor, upload, publish, restore, delete
src/server.ts buildApp (what tests drive) and main (what the container runs)
public/ Hand-written CSS, ~2KB of progressive-enhancement JS, Archivo
config/ Per-Site config, one file per Site
config/ Per-Site brand, theme, limits and the first category list
test/ node:test suites
docs/plan.md The agreed plan
docs/operating.md Publishing, fixing and rolling back, for whoever operates it
@@ -1,3 +1,5 @@
**Status**: superseded by [ADR 0007](./0007-categories-are-content.md).
# Sections come from configuration, not from the admin
A Site's Sections are declared in its config file and change only with a redeploy; the admin manages the Menu Sets inside Sections and cannot create, rename or reorder Sections. Sections are few and stable, and keeping them out of the admin removed a whole class of editing, reordering and deletion edge cases from the content model.
+7
View File
@@ -0,0 +1,7 @@
# Categories are content, not configuration
Sections moved out of `config/<site>.site.json` and into the data directory as `data/sections.json`, managed from the admin: add, rename, reorder and remove. The config's Section list is now only the seed for a Site that has never been opened, which keeps a fresh deployment useful without making a redeploy the price of adding a category. This supersedes ADR 0005.
Two rules make the change safe to live with. A Section's **slug is fixed once created** — the label can change at any time, but the web address cannot, because it is what a table card or a printed QR code points at. And a Section **cannot be removed while it still holds menus**, so a category cannot silently orphan or delete content; the admin says how many sets are in the way.
**Considered Options**: keeping Sections in the config and adding an admin that edits the file (rejected: the config is mounted read-only into the container, and every change would still need a redeploy); storing them in the manifests (rejected: a Section exists before it has any Menu Set, so it needs a home of its own).
+11 -4
View File
@@ -12,7 +12,8 @@ For whoever is on the other end of "the menu is wrong". The design is in
| Menu content | `/var/lib/menu-app.<site>/data` — `originals/`, `drafts/`, `published/` |
| Admin | `https://<vhost>/admin` |
| The password | `/var/lib/menu-app.<site>/site.env` (mode 600, never in git) |
| Sections and branding | `config/<site>.site.json` in this repo, baked into the image |
| Categories | `data/sections.json`, edited in the admin |
| Brand and theme | `config/<site>.site.json` in this repo, baked into the image |
## Everyday tasks
@@ -31,9 +32,15 @@ bash /tmp/provision-site-env.sh /var/lib/menu-app.hopsngrains.com --force
docker restart menu-app.hopsngrains.com
```
**Add, rename or reorder a Section.** Edit `config/<site>.site.json`, rebuild and
push (`./deploy.sh <tag>`), then run the playbook with the new tag. Sections are
deliberately not editable from the admin (ADR 0005).
**Add, rename, reorder or remove a category.** In the admin, open the
**Categories** fold at the bottom of the dashboard. A new category starts empty,
so customers do not see it until you publish a menu into it. Reordering there is
the order customers see. Two rules: a category's web address is fixed when it is
created (rename the name as often as you like — a printed QR code keeps working),
and a category can only be removed once it holds no menus.
**Change brand or theme.** Edit `config/<site>.site.json`, rebuild and push
(`./deploy.sh <tag>`), then run the playbook with the new tag.
**Fix a missing image.** If a Page's thumbnail or viewing image is lost while its
Original survives, the app can rebuild them:
+6 -6
View File
@@ -20,7 +20,7 @@ Replaces the `scp`-a-JPG workflow for `menu.hopsngrains.com` with a small self-h
| Stack | Node + Fastify + `sharp`, one container |
| Metadata | JSON manifests, state encoded in the directory layout (ADR 0002) |
| Scope | One app instance per Site (ADR 0004); other two Sites migrate later |
| Sections | Per-Site config in git; not editable in the admin (ADR 0005) |
| Categories | Content, not configuration: added, renamed, reordered and removed in the admin (ADR 0007, superseding ADR 0005) |
| Lifecycle | Draft → Current → Archive; publishing auto-archives the previous set; archives unlisted but reachable. A published set's Pages are fixed, but the Current set's order can be corrected (ADR 0006) |
| Auth | Single shared password per Site; app login; hash + session secret in an untracked `.env` on the server |
| Images | 1600px viewing WebP + JPEG fallback, 480px admin-only thumbnails, Originals kept and never served |
@@ -29,7 +29,7 @@ Replaces the `scp`-a-JPG workflow for `menu.hopsngrains.com` with a small self-h
## Domain
`CONTEXT.md` is the glossary. Sections for this Site: Food, Bar, Wine, Straight Drinks, Delivery, What's On. One Current Menu Set per Section; a Section with no Current set does not appear on the landing page.
`CONTEXT.md` is the glossary. This Site starts with Food, Bar, Wine, Straight Drinks, Delivery and What's On; the owner adds and renames categories from the admin. One Current Menu Set per Section, and a Section with no Current set does not appear on the landing page.
## Architecture
@@ -64,7 +64,7 @@ caddy (80/443, TLS) ──reverse_proxy──▶ menu-app.hopsngrains.com:3000
| Route | Purpose |
| --- | --- |
| `GET /` | Landing page: Sections with a Current set, text rows |
| `GET /` | Landing page: the shelves, one per Section with a Current set |
| `GET /<section>/` | Current Menu Set |
| `GET /<section>/<slug>/` | Archived Menu Set (unlisted, still reachable) |
| `GET /<section>/#page-N` | Deep link to one Page |
@@ -89,7 +89,7 @@ Protection: a `scrypt` hash (from `node:crypto`, so no native build dependency)
### Customer design
Text-led landing page; Sections as tap-list rows with copper hairlines; no hero photograph, no cards, no shadows.
The chrome is the shelf and the menus are the paper on it: each Section is a shelf row whose right edge is a sliver of that menu's first page, bleeding off the screen. The bar names the menu you are reading and opens a sheet listing the rest, which scales to any number of categories; a 2px brass line under it tracks how far through the pages you are.
```
┌────────────────────────────┐ ┌────────────────────────────┐
@@ -114,7 +114,7 @@ Text-led landing page; Sections as tap-list rows with copper hairlines; no hero
- New repo `menu-admin` on `git.tanshu.com`: app source, `Dockerfile`, `compose.yaml` for local dev, `docs/` (this plan, `CONTEXT.md`, the ADRs), `node --test` tests, and `deploy.sh` (tests, build, push).
- The container is `node:24-alpine` running TypeScript directly, so there is no build step; sharp's musl binaries come from `npm ci` on the image's own platform. It writes only under `MENU_ROOT` and healthchecks its own `/healthz`.
- The Site's Sections, brand and theme live in `config/<site>.site.json`, baked into the image and selected with `MENU_SITE_CONFIG`. Changing Sections is therefore a commit, a new image and a redeploy; the playbook only names the Site.
- The Site's brand and theme live in `config/<site>.site.json`, baked into the image and selected with `MENU_SITE_CONFIG`; its Section list only seeds `data/sections.json` the first time. Categories are edited in the admin from then on (ADR 0007), so adding one does not need a redeploy.
- Build locally and push `registry.tanshu.com/menu-admin:<tag>`, matching the sibling `deploy.sh` / `make build-production TAG=` convention.
- New `menu-admin` playbook in `ansible-playbooks` with per-Site vars (`vars/hopsngrains.yml` first, the other two stubbed): roles `network`, `menu-app` (pull image, create `/var/lib/menu-app.<site>`, start container, health check, then `verify` the data directory) and `caddy` (snippet upsert).
- The existing `roles/caddy` only *adds* a snippet when its exact text is missing, which cannot change a proxy target; the new playbook's role uses a stable marker so `blockinfile` updates in place, and removes the old role's per-hostname marker at cutover so Caddy never holds two blocks for one hostname.
@@ -136,4 +136,4 @@ Deleting the old 319MB directory is the only irreversible step, so it is last.
## Out of scope
Backups (owner already runs them), Prometheus metrics, Promtail/Loki integration, multi-user accounts, scheduled publishing, admin-editable Sections, migrating `menu.hngmohali.com` and `menu.greatbear.in`, and git history for the image content.
Backups (owner already runs them), Prometheus metrics, Promtail/Loki integration, multi-user accounts, scheduled publishing, per-Section theming, migrating `menu.hngmohali.com` and `menu.greatbear.in`, and git history for the image content.
+57
View File
@@ -320,3 +320,60 @@
.login input {
width: 100%;
}
/* ---------- Categories ---------- */
.catlist {
margin: 0.4rem 0 0;
padding: 0;
list-style: none;
}
.catrow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem 0.6rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--hairline-soft);
}
.catrow__name {
display: flex;
flex: 1 1 13rem;
gap: 0.4rem;
min-width: 0;
}
.catrow__name input {
flex: 1 1 auto;
min-width: 0;
padding: 0.4rem 0.5rem;
border: 1px solid var(--hairline);
background: var(--steel);
color: var(--ink);
font: inherit;
font-size: 0.95rem;
}
.catrow__url {
color: var(--ink-dim);
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.catrow__actions {
display: flex;
gap: 0.25rem;
margin-left: auto;
}
.fold--categories > summary {
color: var(--ink);
font-size: 0.95rem;
}
.fold--categories .muted {
margin: 0.5rem 0 0;
}
+48
View File
@@ -117,4 +117,52 @@
openOverlay(link.getAttribute('href'), (image && image.alt) || 'Menu page');
});
});
/* ---------- The menu switcher ----------
The bar names the menu you are reading; tapping it lists the rest. The
switcher is a link to the landing page, so it still works without this
file — the script just opens the sheet instead of navigating. */
var switcher = document.querySelector('[data-switcher]');
var sheet = document.querySelector('[data-sheet]');
var sheetClose = document.querySelector('[data-sheet-close]');
function closeSheet() {
if (!sheet) return;
sheet.hidden = true;
if (switcher) {
switcher.setAttribute('aria-expanded', 'false');
switcher.focus({ preventScroll: true });
}
document.body.style.overflow = '';
}
function openSheet() {
if (!sheet) return;
sheet.hidden = false;
if (switcher) switcher.setAttribute('aria-expanded', 'true');
document.body.style.overflow = 'hidden';
var target = sheet.querySelector('.sheet__item') || sheetClose;
if (target) target.focus({ preventScroll: true });
}
if (switcher && sheet) {
switcher.addEventListener('click', function (event) {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) {
return;
}
event.preventDefault();
if (sheet.hidden) openSheet();
else closeSheet();
});
sheet.addEventListener('click', function (event) {
if (event.target === sheet) closeSheet();
});
if (sheetClose) sheetClose.addEventListener('click', closeSheet);
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape' && !sheet.hidden) closeSheet();
});
}
})();
+266 -94
View File
@@ -1,5 +1,7 @@
/* Hops n Grains menu. Steel is the taproom, paper is the menu: the interface
stays quiet so the photographed pages are the only loud thing. */
/* Hops n Grains menus.
The chrome is the shelf; the menus are the paper standing on it. Brass is the
taproom's metal, the logo's teal is the only active colour, and the paper of
the photographed menus is the brightest thing on the page. */
@font-face {
font-family: 'Archivo';
@@ -25,22 +27,23 @@
}
:root {
--steel: #14181b;
--rail: #1f262b;
--paper: #f2efe9;
--steel: #101719;
--rail: #182125;
--paper: #efeae0;
--accent: #0088aa;
--copper: #b2703b;
--ink: #e8e6e1;
--ink-dim: #99a2a8;
--copper: #c08544;
--ink: #e8e5dd;
--ink-dim: #8f979a;
--ink-on-paper: #1b1f22;
--hairline: color-mix(in srgb, var(--copper) 40%, transparent);
--hairline-soft: color-mix(in srgb, var(--copper) 22%, transparent);
--brass-edge: color-mix(in srgb, var(--copper) 42%, transparent);
--hairline: color-mix(in srgb, var(--copper) 26%, transparent);
--hairline-soft: color-mix(in srgb, var(--copper) 14%, transparent);
--sheet-shadow: color-mix(in srgb, #000 45%, transparent);
--step-0: 1rem;
--step-1: 1.25rem;
--step-2: 1.55rem;
--step-3: 2.4rem;
--page-max: 780px;
--content-max: 56rem;
--content-max: 52rem;
}
*,
@@ -60,6 +63,7 @@ body {
font-family: 'Archivo', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
font-size: var(--step-0);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
a {
@@ -72,73 +76,118 @@ a {
outline-offset: 3px;
}
/* ---------- Landing ---------- */
/* ---------- Landing: shelves holding menus ---------- */
.landing {
max-width: var(--content-max);
margin: 0 auto;
padding: 0 1.25rem 3rem;
padding: 0 1.1rem 3rem;
}
.masthead {
padding: clamp(2.5rem, 11vh, 5.5rem) 0 1.75rem;
padding: clamp(2.2rem, 10vh, 4.5rem) 0 1.6rem;
}
.wordmark {
margin: 0;
font-size: clamp(1.9rem, 8vw, 2.9rem);
font-weight: 600;
font-stretch: 122%;
letter-spacing: -0.01em;
line-height: 1.05;
font-size: clamp(1.95rem, 8.5vw, 2.9rem);
font-weight: 700;
font-stretch: 118%;
letter-spacing: -0.025em;
line-height: 1;
}
.masthead__tagline {
margin: 0.4rem 0 0;
margin: 0.5rem 0 0;
color: var(--ink-dim);
font-size: var(--step-1);
font-size: 0.98rem;
}
.rows {
border-top: 1px solid var(--hairline);
}
.row {
.shelves {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
padding: 1.1rem 0.15rem;
border-bottom: 1px solid var(--hairline);
flex-direction: column;
margin-right: -1.1rem;
}
.row__label {
font-size: clamp(1.45rem, 6.5vw, 2rem);
font-weight: 600;
font-stretch: 116%;
line-height: 1.15;
.shelf {
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 0.3rem;
min-height: 6.6rem;
padding: 1.2rem 6.9rem 1.25rem 0.1rem;
overflow: hidden;
border-top: 1px solid var(--brass-edge);
}
.row__meta {
color: var(--ink-dim);
font-size: 0.95rem;
font-variant-numeric: tabular-nums;
white-space: nowrap;
.shelf:last-child {
border-bottom: 1px solid var(--brass-edge);
}
.row:hover .row__label,
.row:focus-visible .row__label {
/* the shadow the shelf above casts onto what it holds */
.shelf::after {
content: '';
position: absolute;
top: 0;
right: 0;
left: 0;
height: 1.3rem;
background: linear-gradient(var(--sheet-shadow), transparent);
pointer-events: none;
}
.shelf__peek {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 4.9rem;
box-shadow: -1px 0 0 var(--sheet-shadow);
}
.shelf__peek img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: top center;
transform: translateY(4%) scale(1.02);
transition: transform 0.35s cubic-bezier(0.2, 0.7, 0.2, 1);
}
.shelf:hover .shelf__peek img,
.shelf:focus-visible .shelf__peek img {
transform: translateY(0) scale(1.07);
}
.shelf__name {
font-size: clamp(1.5rem, 6.4vw, 2rem);
font-weight: 700;
font-stretch: 112%;
letter-spacing: -0.02em;
line-height: 1.02;
}
.shelf:hover .shelf__name,
.shelf:focus-visible .shelf__name {
color: var(--accent);
}
.shelf__pages {
color: var(--ink-dim);
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
}
.empty {
max-width: 34ch;
margin: 0;
margin: 1rem 0 0;
color: var(--ink-dim);
font-size: var(--step-1);
}
/* ---------- Section page: the rail and the pour line ---------- */
/* ---------- Section page: the bar and the switcher ---------- */
.bar {
position: sticky;
@@ -146,42 +195,50 @@ a {
z-index: 20;
display: flex;
align-items: center;
gap: 0.85rem;
padding: 0.5rem 0.9rem 0.4rem;
background: var(--rail);
gap: 0.7rem;
padding: 0.5rem 0.85rem 0.45rem;
background: color-mix(in srgb, var(--rail) 94%, transparent);
backdrop-filter: blur(6px);
}
.bar__brand {
font-size: 0.95rem;
font-weight: 600;
font-stretch: 118%;
white-space: nowrap;
}
.rail {
display: flex;
flex: 1 1 auto;
gap: 0.15rem;
overflow-x: auto;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
align-items: center;
}
.rail::-webkit-scrollbar {
display: none;
.bar__brand img {
display: block;
width: 24px;
height: 24px;
}
.rail__item {
padding: 0.35rem 0.6rem;
color: var(--ink-dim);
font-size: 0.95rem;
white-space: nowrap;
}
.rail__item.is-current {
background: var(--accent);
color: var(--steel);
.switcher {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.3rem 0.5rem;
border: 1px solid transparent;
color: var(--ink);
font-size: 1.05rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.switcher:hover {
border-color: var(--brass-edge);
}
.switcher__caret {
width: 0.45rem;
height: 0.45rem;
border-right: 1.5px solid var(--copper);
border-bottom: 1.5px solid var(--copper);
transform: rotate(45deg) translate(-0.1rem, -0.1rem);
transition: transform 0.2s ease;
}
.switcher[aria-expanded='true'] .switcher__caret {
transform: rotate(-135deg) translate(-0.05rem, -0.05rem);
}
.pour {
@@ -200,25 +257,130 @@ a {
background: var(--copper);
}
/* ---------- The switcher sheet ---------- */
.sheet {
position: fixed;
inset: 0;
z-index: 40;
display: flex;
align-items: flex-end;
background: color-mix(in srgb, #05090a 74%, transparent);
}
.sheet[hidden] {
display: none;
}
.sheet__inner {
width: 100%;
max-height: 88vh;
overflow-y: auto;
padding: 1.1rem 1rem calc(1.2rem + env(safe-area-inset-bottom));
background: var(--rail);
border-top: 2px solid var(--copper);
animation: sheet-rise 0.24s cubic-bezier(0.2, 0.7, 0.2, 1);
}
@keyframes sheet-rise {
from {
transform: translateY(1.6rem);
opacity: 0.5;
}
to {
transform: none;
opacity: 1;
}
}
.sheet__title {
margin: 0 0 0.5rem;
color: var(--ink-dim);
font-size: 0.85rem;
}
.sheet__list {
margin: 0;
padding: 0;
list-style: none;
}
.sheet__item {
display: grid;
grid-template-columns: 2.4rem 1fr auto;
align-items: center;
gap: 0.85rem;
padding: 0.5rem 0.4rem;
border-bottom: 1px solid var(--hairline-soft);
}
.sheet__item.is-current {
background: color-mix(in srgb, var(--accent) 13%, transparent);
}
.sheet__peek {
width: 2.4rem;
height: 3rem;
overflow: hidden;
background: var(--paper);
}
.sheet__peek img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: top center;
}
.sheet__name {
font-size: 1.12rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.sheet__state {
color: var(--ink-dim);
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
}
.sheet__item.is-current .sheet__state {
color: var(--accent);
}
.sheet__close {
margin-top: 0.9rem;
padding: 0.5rem 0.85rem;
border: 1px solid var(--brass-edge);
background: none;
color: var(--ink);
font: inherit;
font-size: 0.9rem;
cursor: pointer;
}
/* ---------- The pages themselves ---------- */
.section-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
max-width: var(--page-max);
margin: 1.35rem auto 0.85rem;
padding: 0 1rem;
margin: 1.2rem auto 0.75rem;
padding: 0 0.9rem;
}
.section-heading__label {
font-size: var(--step-2);
font-weight: 600;
font-stretch: 116%;
font-weight: 700;
letter-spacing: -0.02em;
}
.section-heading__date {
color: var(--ink-dim);
font-size: 0.9rem;
font-size: 0.88rem;
font-variant-numeric: tabular-nums;
}
@@ -239,20 +401,19 @@ a {
.notice--admin {
max-width: none;
border-left-color: var(--accent);
background: color-mix(in srgb, var(--accent) 22%, var(--rail));
background: color-mix(in srgb, var(--accent) 20%, var(--rail));
}
/* ---------- The Pages ---------- */
.pages {
max-width: var(--page-max);
margin: 0 auto;
padding: 0 0.75rem 1.25rem;
padding: 0 0.7rem 1.25rem;
}
.page {
margin: 0 0 0.7rem;
margin: 0 0 0.6rem;
background: var(--paper);
box-shadow: 0 1px 0 color-mix(in srgb, #000 35%, transparent);
}
.page__zoom {
@@ -270,7 +431,7 @@ a {
.foot {
max-width: var(--page-max);
margin: 0 auto 4.5rem;
padding: 0 1rem 1rem;
padding: 0 0.9rem 1rem;
border-top: 1px solid var(--hairline-soft);
}
@@ -281,7 +442,7 @@ a {
.fold summary {
color: var(--ink-dim);
font-size: 0.95rem;
font-size: 0.92rem;
cursor: pointer;
}
@@ -332,7 +493,7 @@ a {
.older__date {
color: var(--ink-dim);
font-size: 0.9rem;
font-size: 0.88rem;
font-variant-numeric: tabular-nums;
}
@@ -349,18 +510,18 @@ a {
.counter {
position: fixed;
right: 0.9rem;
bottom: calc(0.9rem + env(safe-area-inset-bottom));
right: 0.85rem;
bottom: calc(0.85rem + env(safe-area-inset-bottom));
z-index: 30;
display: inline-flex;
align-items: baseline;
gap: 0.3rem;
padding: 0.45rem 0.7rem;
border: 1px solid var(--hairline);
border: 1px solid var(--brass-edge);
background: var(--paper);
color: var(--ink-on-paper);
font: inherit;
font-size: 0.9rem;
font-size: 0.88rem;
font-variant-numeric: tabular-nums;
cursor: pointer;
}
@@ -376,9 +537,9 @@ a {
.zoom {
position: fixed;
inset: 0;
z-index: 40;
z-index: 50;
overflow: auto;
background: rgba(8, 10, 12, 0.95);
background: rgba(6, 9, 10, 0.96);
-webkit-overflow-scrolling: touch;
}
@@ -395,7 +556,7 @@ a {
right: 0.6rem;
z-index: 1;
padding: 0.5rem 0.75rem;
border: 1px solid var(--hairline);
border: 1px solid var(--brass-edge);
background: var(--paper);
color: var(--ink-on-paper);
font: inherit;
@@ -404,7 +565,17 @@ a {
/* ---------- Wider screens ---------- */
@media (min-width: 60rem) {
@media (min-width: 62rem) {
.shelves {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 2.5rem;
}
.shelf {
padding-right: 5.6rem;
}
.pages {
max-width: 820px;
}
@@ -412,6 +583,7 @@ a {
@media print {
.bar,
.sheet,
.counter,
.fold,
.notice {
+10 -3
View File
@@ -12,6 +12,7 @@ import sharp from 'sharp';
import { parseSiteConfig } from '../src/config.ts';
import { createImagePipeline } from '../src/images/pipeline.ts';
import { SetStore } from '../src/storage/sets.ts';
import { SectionStore } from '../src/storage/sections.ts';
const root = path.resolve(process.env['MENU_ROOT'] ?? '.dev');
const configPath = path.resolve(
@@ -61,18 +62,24 @@ const plan: { section: string; label: string; validUntil: string | null; pages:
{ section: 'whats-on', label: "What's On", validUntil: null, pages: 2 },
];
await rm(dataDir, { recursive: true, force: true });
await mkdir(dataDir, { recursive: true });
for (const dir of ['drafts', 'published', 'originals']) {
await rm(path.join(dataDir, dir), { recursive: true, force: true });
}
// Seeding replaces the menus, not the category list.
const sections = new SectionStore({ dataDir, seed: config.sections });
await sections.load();
const store = new SetStore({
dataDir,
pipeline: createImagePipeline(),
sections: config.sections.map((section) => section.slug),
sections,
maxPagesPerSet: config.limits.maxPagesPerSet,
});
for (const [index, entry] of plan.entries()) {
const section = config.sections.find((candidate) => candidate.slug === entry.section);
const section = sections.bySlug(entry.section);
if (section === undefined) continue;
const set = await store.create(entry.section, { label: entry.label, validUntil: entry.validUntil });
for (let page = 1; page <= entry.pages; page += 1) {
+5 -4
View File
@@ -6,13 +6,14 @@
*/
import { hasFatal, validateSite } from '../storage/validate.ts';
import { SectionStore } from '../storage/sections.ts';
import { loadCliSite } from './site.ts';
const { config, dataDir } = await loadCliSite();
const problems = await validateSite({
dataDir,
sections: config.sections.map((section) => section.slug),
});
const sections = new SectionStore({ dataDir, seed: config.sections });
await sections.load();
const problems = await validateSite({ dataDir, sections });
if (problems.length === 0) {
console.log(`ok: ${dataDir}`);
+53 -1
View File
@@ -88,17 +88,67 @@ function setRow(section: SectionConfig, set: StoredSet, kind: 'current' | 'draft
export interface DashboardInput {
config: SiteConfig;
/** Every Section, in order: the Site's own categories, not configuration. */
sections: readonly SectionConfig[];
sets: readonly StoredSet[];
current: ReadonlyMap<string, StoredSet>;
csrf: string;
notice: string | null;
error: string | null;
}
function renderCategories(input: DashboardInput): Html {
const rows = input.sections.map(
(section, index) => html`<li class="catrow">
<form class="catrow__name" method="post" action="/admin/sections/${section.slug}/rename">
<input type="hidden" name="csrf" value="${input.csrf}" />
<input type="text" name="label" value="${section.label}" aria-label="Name shown for ${section.label}" required />
<button class="button button--icon" type="submit">Save</button>
</form>
<span class="catrow__url">/${section.slug}/</span>
<span class="catrow__actions">
<form method="post" action="/admin/sections/${section.slug}/move">
<input type="hidden" name="csrf" value="${input.csrf}" />
<input type="hidden" name="direction" value="up" />
<button class="button button--icon" type="submit" title="Move up" ${index === 0 ? 'disabled' : ''}>↑</button>
</form>
<form method="post" action="/admin/sections/${section.slug}/move">
<input type="hidden" name="csrf" value="${input.csrf}" />
<input type="hidden" name="direction" value="down" />
<button class="button button--icon" type="submit" title="Move down" ${index === input.sections.length - 1 ? 'disabled' : ''}>↓</button>
</form>
<form
method="post"
action="/admin/sections/${section.slug}/delete"
data-confirm="Remove the ${section.label} category?"
>
<input type="hidden" name="csrf" value="${input.csrf}" />
<button class="button button--icon button--danger" type="submit" title="Remove category">✕</button>
</form>
</span>
</li>`,
);
return html`<details class="fold fold--categories">
<summary>Categories (${input.sections.length})</summary>
<p class="muted">
The order here is the order customers see. A name can change at any time; the web address
beside it cannot, because that is what a table card or QR code points at.
</p>
<ul class="catlist">${join(rows)}</ul>
<form class="newset" method="post" action="/admin/sections">
<input type="hidden" name="csrf" value="${input.csrf}" />
${labelled('New category', html`<input type="text" name="label" placeholder="Desserts" required />`)}
<button class="button" type="submit">Add category</button>
</form>
</details>`;
}
export function renderDashboard(input: DashboardInput): Html {
const { config } = input;
const bySection = (slug: string) => input.sets.filter((set) => set.section === slug);
const blocks = config.sections.map((section) => {
const blocks = input.sections.map((section) => {
const all = bySection(section.slug);
const current = input.current.get(section.slug);
const drafts = all.filter((set) => set.state === 'draft');
@@ -147,7 +197,9 @@ export function renderDashboard(input: DashboardInput): Html {
</form>
</header>
${input.notice === null ? '' : html`<p class="notice">${input.notice}</p>`}
${input.error === null ? '' : html`<p class="notice notice--error">${input.error}</p>`}
${blocks}
${renderCategories(input)}
</main>`,
});
}
+101 -29
View File
@@ -24,6 +24,23 @@ export function mediaUrls(page: PageRecord): MediaUrls {
return { webp, jpeg, thumb: `/media/thumb-${page.id}.${page.hash}.webp`, view: webp };
}
export function menuLinks(
sections: readonly SectionConfig[],
current: ReadonlyMap<string, StoredSet>,
): MenuLink[] {
return sections
.filter((section) => current.has(section.slug))
.map((section) => {
const set = current.get(section.slug)!;
return { section, cover: coverUrl(set), pages: set.manifest.pages.length };
});
}
export function coverUrl(set: StoredSet): string | null {
const first = set.manifest.pages[0];
return first === undefined ? null : mediaUrls(first).thumb;
}
export function pageCountLabel(count: number): string {
return count === 1 ? '1 page' : `${count} pages`;
}
@@ -47,22 +64,60 @@ export function setDateLabel(manifest: SetManifest): string {
return 'not published';
}
function renderRail(config: SiteConfig, currentSection: string, published: ReadonlySet<string>): Html {
return html`<nav class="rail" aria-label="Menus">
${join(
config.sections
.filter((section) => published.has(section.slug))
.map(
(section) =>
html`<a
class="rail__item${section.slug === currentSection ? ' is-current' : ''}"
href="/${section.slug}/"
${section.slug === currentSection ? html`aria-current="page"` : ''}
>${section.shortLabel}</a
>`,
),
)}
</nav>`;
interface SheetEntry {
section: SectionConfig;
current: boolean;
cover: string | null;
pages: number;
}
/**
* The bar names the menu you are reading and opens a sheet with the rest. A row
* of tabs was unreadable at six categories and would not survive more.
* The switcher is a link to the landing page, so it still works without the
* script; the script opens the sheet instead of navigating.
*/
function renderBar(config: SiteConfig, currentLabel: string): Html {
return html`<header class="bar">
<a class="bar__brand" href="/" aria-label="${config.brand.name} — all menus">
<img src="${config.brand.logo ?? '/assets/favicon.svg'}" alt="" width="24" height="24" />
</a>
<a class="switcher" href="/" data-switcher aria-expanded="false" aria-controls="menu-sheet">
<span class="switcher__label">${currentLabel}</span>
<span class="switcher__caret" aria-hidden="true"></span>
</a>
<div class="pour" aria-hidden="true"><span class="pour__fill" data-pour></span></div>
</header>`;
}
function renderSheet(config: SiteConfig, entries: readonly SheetEntry[]): Html {
return html`<section class="sheet" id="menu-sheet" data-sheet hidden aria-label="Menus">
<div class="sheet__inner">
<p class="sheet__title">${config.brand.name}</p>
<ul class="sheet__list">
${join(
entries.map(
(entry) => html`<li>
<a
class="sheet__item${entry.current ? ' is-current' : ''}"
href="/${entry.section.slug}/"
${entry.current ? html`aria-current="page"` : ''}
>
<span class="sheet__peek" aria-hidden="true">
${entry.cover === null
? ''
: html`<img src="${entry.cover}" alt="" loading="lazy" decoding="async" />`}
</span>
<span class="sheet__name">${entry.section.label}</span>
<span class="sheet__state">${entry.current ? 'Showing' : pageCountLabel(entry.pages)}</span>
</a>
</li>`,
),
)}
</ul>
<button class="sheet__close" type="button" data-sheet-close>Close</button>
</div>
</section>`;
}
function renderPages(section: SectionConfig, pages: readonly PageRecord[]): Html {
@@ -125,10 +180,17 @@ function renderOlderMenus(section: SectionConfig, archives: readonly StoredSet[]
</details>`;
}
export interface MenuLink {
section: SectionConfig;
/** Top of the first Page, for the paper sliver. */
cover: string | null;
pages: number;
}
export interface LandingInput {
config: SiteConfig;
/** Sections that have a Current Menu Set, in config order. */
live: readonly { section: SectionConfig; set: StoredSet }[];
/** Sections that have a Current Menu Set, in the order customers should see. */
live: readonly MenuLink[];
}
export function renderLanding(input: LandingInput): Html {
@@ -136,12 +198,17 @@ export function renderLanding(input: LandingInput): Html {
const rows =
input.live.length === 0
? html`<p class="empty">The menus are being updated. Please ask a member of staff.</p>`
: html`<nav class="rows" aria-label="Menus">
: html`<nav class="shelves" aria-label="Menus">
${join(
input.live.map(
({ section, set }) => html`<a class="row" href="/${section.slug}/">
<span class="row__label">${section.label}</span>
<span class="row__meta">${pageCountLabel(set.manifest.pages.length)}</span>
(entry) => html`<a class="shelf" href="/${entry.section.slug}/">
${entry.cover === null
? ''
: html`<span class="shelf__peek" aria-hidden="true"
><img src="${entry.cover}" alt="" loading="lazy" decoding="async"
/></span>`}
<span class="shelf__name">${entry.section.label}</span>
<span class="shelf__pages">${pageCountLabel(entry.pages)}</span>
</a>`,
),
)}
@@ -171,8 +238,8 @@ export interface SectionInput {
config: SiteConfig;
section: SectionConfig;
set: StoredSet;
/** Section slugs that have a Current Menu Set, for the rail. */
published: ReadonlySet<string>;
/** Every Section with a Current Menu Set, in order, for the switcher. */
menu: readonly MenuLink[];
current: boolean;
archives: readonly StoredSet[];
/** Draft preview for the admin: adds a banner and skips the archive fold. */
@@ -184,11 +251,16 @@ export function renderSectionPage(input: SectionInput): Html {
const currentHref = `/${section.slug}/`;
const total = set.manifest.pages.length;
const body = html`<header class="bar">
<a class="bar__brand" href="/">${config.brand.name}</a>
${renderRail(config, section.slug, input.published)}
<div class="pour" aria-hidden="true"><span class="pour__fill" data-pour></span></div>
</header>
const body = html`${renderBar(config, section.label)}
${renderSheet(
config,
input.menu.map((entry) => ({
section: entry.section,
current: entry.section.slug === section.slug,
cover: entry.cover,
pages: entry.pages,
})),
)}
${input.preview
? html`<p class="notice notice--admin">
Preview of a Draft — customers cannot see this yet.
+102 -12
View File
@@ -15,13 +15,14 @@ import {
verifyPassword,
verifySessionToken,
} from '../auth.ts';
import { sectionBySlug, type SiteConfig } from '../config.ts';
import type { SiteConfig } from '../config.ts';
import { renderDashboard, renderEditor, renderLogin } from '../render/admin.ts';
import { renderSectionPage } from '../render/pages.ts';
import { menuLinks, renderSectionPage } from '../render/pages.ts';
import { UnsupportedImageError } from '../images/pipeline.ts';
import type { SetStore, StoredSet } from '../storage/sets.ts';
import { StoreError } from '../storage/sets.ts';
import { sessionCookieAttributes, SESSION_TTL_MS } from '../server.ts';
import type { SectionStore } from '../storage/sections.ts';
const HTML = 'text/html; charset=utf-8';
@@ -34,6 +35,7 @@ export function describeBytes(bytes: number): string {
export interface AdminRouteDeps {
config: SiteConfig;
sections: SectionStore;
store: SetStore;
sessionSecret: string;
adminPasswordHash: string | null;
@@ -63,7 +65,7 @@ export function sameOrigin(request: FastifyRequest): boolean {
}
export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRouteDeps): Promise<void> {
const { config, store } = deps;
const { config, sections, store } = deps;
const throttle = deps.throttle ?? new LoginThrottle();
const sessionToken = (request: FastifyRequest): string | undefined =>
@@ -82,13 +84,23 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
const wantsJson = (request: FastifyRequest): boolean =>
(request.headers['accept'] ?? '').includes('application/json');
const back = (request: FastifyRequest, reply: FastifyReply, notice: string | null) => {
const backWith = (
request: FastifyRequest,
reply: FastifyReply,
message: { notice?: string | undefined; error?: string | undefined },
) => {
const target = request.headers['referer'];
const base = typeof target === 'string' ? new URL(target, 'http://x').pathname : '/admin';
const url = notice === null ? base : `${base}?notice=${encodeURIComponent(notice)}`;
return reply.redirect(url, 303);
const query = new URLSearchParams();
if (message.notice !== undefined) query.set('notice', message.notice);
if (message.error !== undefined) query.set('error', message.error);
const suffix = query.toString();
return reply.redirect(suffix === '' ? base : `${base}?${suffix}`, 303);
};
const back = (request: FastifyRequest, reply: FastifyReply, notice: string | null) =>
backWith(request, reply, notice === null ? {} : { notice });
const guard = async (
request: FastifyRequest,
reply: FastifyReply,
@@ -169,15 +181,17 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
app.get('/admin/', async (request, reply) => {
if (!authed(request)) return reply.redirect('/admin/login', 303);
const notice = (request.query as { notice?: string }).notice ?? null;
const query = request.query as { notice?: string; error?: string };
return sendHtml(
reply,
renderDashboard({
config,
sections: sections.list(),
sets: await store.list(),
current: await store.currentBySection(),
csrf: csrfToken(sessionToken(request), deps.sessionSecret),
notice,
notice: query.notice ?? null,
error: query.error ?? null,
}).value,
);
});
@@ -190,7 +204,7 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
return reply.code(403).type(HTML).send('That request was rejected. Reload the page and try again.');
}
const body = request.body as { section?: string; label?: string; validUntil?: string };
const section = sectionBySlug(config, body.section ?? '');
const section = sections.bySlug(body.section ?? '');
if (section === undefined) return reply.code(400).send('Unknown Section.');
try {
const created = await store.create(section.slug, {
@@ -211,7 +225,7 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
} catch {
return reply.code(404).type(HTML).send('That Menu Set no longer exists.');
}
const section = sectionBySlug(config, set.section);
const section = sections.bySlug(set.section);
if (section === undefined) return reply.code(404).send('Unknown Section.');
const query = request.query as { notice?: string; error?: string };
const current = (await store.currentBySection()).get(set.section);
@@ -232,7 +246,7 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
app.get<{ Params: { id: string } }>('/admin/sets/:id/preview', async (request, reply) => {
if (!authed(request)) return reply.redirect('/admin/login', 303);
const set = await store.get(request.params.id);
const section = sectionBySlug(config, set.section);
const section = sections.bySlug(set.section);
if (section === undefined) return reply.code(404).send('Unknown Section.');
const current = (await store.currentBySection()).get(section.slug);
return sendHtml(
@@ -241,7 +255,7 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
config,
section,
set,
published: new Set((await store.currentBySection()).keys()),
menu: menuLinks(sections.list(), await store.currentBySection()),
current: current?.id === set.id,
preview: set.state === 'draft',
archives: [],
@@ -299,6 +313,82 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
return reply.redirect('/admin', 303);
});
// ---------- Categories ----------
const guardAdmin = (request: FastifyRequest, reply: FastifyReply): boolean => {
if (!authed(request)) {
reply.redirect('/admin/login', 303);
return false;
}
if (!sameOrigin(request) || !csrfOk(request)) {
reply.code(403).type(HTML).send('That request was rejected. Reload the page and try again.');
return false;
}
return true;
};
const reason = (error: unknown): string =>
error instanceof Error ? error.message : 'That did not work.';
app.post('/admin/sections', async (request, reply) => {
if (!guardAdmin(request, reply)) return reply;
const body = request.body as { label?: string };
try {
const created = await sections.create({ label: body.label ?? '' });
return backWith(request, reply, {
notice: `${created.label} added. Its web address is /${created.slug}/ — that stays put even if you rename it.`,
});
} catch (error) {
return backWith(request, reply, { error: reason(error) });
}
});
app.post<{ Params: { slug: string } }>('/admin/sections/:slug/rename', async (request, reply) => {
if (!guardAdmin(request, reply)) return reply;
const body = request.body as { label?: string };
try {
const renamed = await sections.rename(request.params.slug, { label: body.label ?? '' });
return backWith(request, reply, {
notice: `${renamed.label} renamed. Its web address is still /${renamed.slug}/.`,
});
} catch (error) {
return backWith(request, reply, { error: reason(error) });
}
});
app.post<{ Params: { slug: string } }>('/admin/sections/:slug/move', async (request, reply) => {
if (!guardAdmin(request, reply)) return reply;
const body = request.body as { direction?: string };
try {
await sections.move(request.params.slug, body.direction === 'up' ? 'up' : 'down');
} catch (error) {
return backWith(request, reply, { error: reason(error) });
}
return backWith(request, reply, {});
});
app.post<{ Params: { slug: string } }>('/admin/sections/:slug/delete', async (request, reply) => {
if (!guardAdmin(request, reply)) return reply;
const existing = sections.bySlug(request.params.slug);
if (existing === undefined) {
return backWith(request, reply, { error: `No such category: ${request.params.slug}` });
}
const affected = (await store.list()).filter((set) => set.section === existing.slug);
if (affected.length > 0) {
return backWith(request, reply, {
error:
`${existing.label} still holds ${affected.length} menu ` +
`${affected.length === 1 ? 'set' : 'sets'}. Delete those first, or rename the category instead.`,
});
}
try {
await sections.remove(existing.slug);
} catch (error) {
return backWith(request, reply, { error: reason(error) });
}
return backWith(request, reply, { notice: `${existing.label} removed.` });
});
// ---------- Pages ----------
app.post<{ Params: { id: string } }>('/admin/sets/:id/pages', async (request, reply) => {
+18 -16
View File
@@ -2,17 +2,24 @@ import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import path from 'node:path';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { sectionBySlug, type SiteConfig } from '../config.ts';
import type { SiteConfig } from '../config.ts';
import type { SectionStore } from '../storage/sections.ts';
import { SESSION_COOKIE, verifySessionToken } from '../auth.ts';
import { variantFileNames } from '../storage/manifest.ts';
import type { SetStore } from '../storage/sets.ts';
import { renderLanding, renderNotFound, renderSectionPage } from '../render/pages.ts';
import {
menuLinks,
renderLanding,
renderNotFound,
renderSectionPage,
} from '../render/pages.ts';
const HTML = 'text/html; charset=utf-8';
const MEDIA_NAME = /^(thumb-)?([0-9a-f-]{36})\.([0-9a-f]{8,64})\.(webp|jpg)$/;
export interface PublicRouteDeps {
config: SiteConfig;
sections: SectionStore;
store: SetStore;
sessionSecret: string;
}
@@ -43,13 +50,12 @@ export async function registerPublicRoutes(
app: FastifyInstance,
deps: PublicRouteDeps,
): Promise<void> {
const { config, store } = deps;
const { config, sections, store } = deps;
const isAuthed = (request: FastifyRequest): boolean =>
verifySessionToken(request.cookies[SESSION_COOKIE], deps.sessionSecret);
const publishedSections = async (): Promise<Set<string>> =>
new Set((await store.currentBySection()).keys());
const menu = async () => menuLinks(sections.list(), await store.currentBySection());
app.get('/healthz', async (_request, reply) => reply.type('text/plain').send('ok'));
@@ -58,21 +64,17 @@ export async function registerPublicRoutes(
);
app.get('/', async (_request, reply) => {
const current = await store.currentBySection();
const live = config.sections
.filter((section) => current.has(section.slug))
.map((section) => ({ section, set: current.get(section.slug)! }));
return sendHtml(reply, renderLanding({ config, live }).value);
return sendHtml(reply, renderLanding({ config, live: await menu() }).value);
});
app.get<{ Params: { section: string } }>('/:section/', async (request, reply) => {
const section = sectionBySlug(config, request.params.section);
const section = sections.bySlug(request.params.section);
if (section === undefined) {
return sendHtml(reply, renderNotFound(config, []).value, 404);
}
const current = (await store.currentBySection()).get(section.slug);
if (current === undefined) {
return sendHtml(reply, renderNotFound(config, config.sections).value, 404);
return sendHtml(reply, renderNotFound(config, sections.list()).value, 404);
}
return sendHtml(
reply,
@@ -80,7 +82,7 @@ export async function registerPublicRoutes(
config,
section,
set: current,
published: await publishedSections(),
menu: await menu(),
current: true,
preview: false,
archives: await store.archives(section.slug),
@@ -91,7 +93,7 @@ export async function registerPublicRoutes(
app.get<{ Params: { section: string; slug: string } }>(
'/:section/:slug/',
async (request, reply) => {
const section = sectionBySlug(config, request.params.section);
const section = sections.bySlug(request.params.section);
if (section === undefined) {
return sendHtml(reply, renderNotFound(config, []).value, 404);
}
@@ -102,7 +104,7 @@ export async function registerPublicRoutes(
candidate.slug === request.params.slug,
);
if (set === undefined) {
return sendHtml(reply, renderNotFound(config, config.sections).value, 404);
return sendHtml(reply, renderNotFound(config, sections.list()).value, 404);
}
const current = (await store.currentBySection()).get(section.slug);
const archives = (await store.archives(section.slug)).filter(
@@ -114,7 +116,7 @@ export async function registerPublicRoutes(
config,
section,
set,
published: await publishedSections(),
menu: await menu(),
current: current?.id === set.id,
preview: false,
archives,
+19 -6
View File
@@ -15,6 +15,7 @@ import { parseSiteConfig, type SiteConfig } from './config.ts';
import { SESSION_COOKIE, sessionTtl, type LoginThrottle } from './auth.ts';
import { createImagePipeline } from './images/pipeline.ts';
import { SetStore, type ImagePipeline } from './storage/sets.ts';
import { SectionStore } from './storage/sections.ts';
import { hasFatal, validateSite } from './storage/validate.ts';
import { renderNotFound } from './render/pages.ts';
import { registerPublicRoutes } from './routes/public.ts';
@@ -26,6 +27,7 @@ export const SESSION_TTL_MS = 8 * 60 * 60 * 1000;
/** What every route reaches for: the store, the session secret, the admin hash. */
export interface MenuContext {
store: SetStore;
sections: SectionStore;
sessionSecret: string;
sessionTtlMs: number;
adminPasswordHash: string | null;
@@ -53,6 +55,8 @@ export interface AppOptions {
now?: () => Date;
/** Test seam: a shared throttle so rate-limit tests are deterministic. */
throttle?: LoginThrottle;
/** Created and loaded by main so boot validation sees the same list. */
sectionsStore?: SectionStore;
}
export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
@@ -80,22 +84,29 @@ export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
maxAge: '5m',
});
const sections =
options.sectionsStore ??
new SectionStore({ dataDir: options.dataDir, seed: options.config.sections });
await sections.load();
const store = new SetStore({
dataDir: options.dataDir,
pipeline: options.pipeline ?? createImagePipeline(),
sections: options.config.sections.map((section) => section.slug),
sections,
maxPagesPerSet: options.config.limits.maxPagesPerSet,
now: options.now,
});
await registerPublicRoutes(app, {
config: options.config,
sections,
store,
sessionSecret: options.sessionSecret,
});
await registerAdminRoutes(app, {
config: options.config,
sections,
store,
sessionSecret: options.sessionSecret,
adminPasswordHash: options.adminPasswordHash,
@@ -106,7 +117,7 @@ export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
app.setNotFoundHandler(async (_request, reply) => {
const current = await store.currentBySection();
const live = options.config.sections.filter((section) => current.has(section.slug));
const live = sections.list().filter((section) => current.has(section.slug));
return reply
.code(404)
.type('text/html; charset=utf-8')
@@ -121,6 +132,7 @@ export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
app.decorate('menu', {
store,
sections,
sessionSecret: options.sessionSecret,
sessionTtlMs: SESSION_TTL_MS,
adminPasswordHash: options.adminPasswordHash,
@@ -162,10 +174,10 @@ async function main(): Promise<void> {
console.warn('ADMIN_PASSWORD_HASH is not set; the admin is disabled');
}
const problems = await validateSite({
dataDir,
sections: config.sections.map((section) => section.slug),
});
const sections = new SectionStore({ dataDir, seed: config.sections });
await sections.load();
const problems = await validateSite({ dataDir, sections });
for (const problem of problems) {
const line = `${problem.level}: ${problem.message}`;
if (problem.level === 'fatal') console.error(line);
@@ -179,6 +191,7 @@ async function main(): Promise<void> {
const app = await buildApp({
config,
sectionsStore: sections,
dataDir,
sessionSecret,
adminPasswordHash,
+191
View File
@@ -0,0 +1,191 @@
/**
* Sections are Site *content*, not Site configuration: they can be added,
* renamed, reordered and removed from the admin. The list lives in the data
* directory, so it is covered by the same backup as the menus, and the config
* file's Section list is only the seed for a Site that has never been opened.
*
* A Section's URL is fixed once created: labels change, slugs do not, because
* the URL is what gets printed on a table card or a QR code.
*/
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { RESERVED_SLUGS, type SectionConfig } from '../config.ts';
import { writeJsonAtomic } from './manifest.ts';
export class SectionError extends Error {
readonly status: number;
constructor(message: string, status = 400) {
super(message);
this.name = 'SectionError';
this.status = status;
}
}
/** "What's On" -> "whats-on", "Straight Drinks" -> "straight-drinks" */
export function slugify(label: string): string {
return label
.normalize('NFKD')
.replace(/['’`]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function cleanLabel(label: unknown, where = 'label'): string {
if (typeof label !== 'string' || label.trim() === '') {
throw new SectionError(`${where} cannot be empty`);
}
return label.trim().replace(/\s+/g, ' ');
}
function parse(raw: unknown): SectionConfig[] {
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
throw new SectionError('sections.json must be an object');
}
const sections = (raw as { sections?: unknown }).sections;
if (!Array.isArray(sections)) throw new SectionError('sections.json must hold a sections array');
const seen = new Set<string>();
const parsed = sections.map((entry, index) => {
if (typeof entry !== 'object' || entry === null) {
throw new SectionError(`sections[${index}] must be an object`);
}
const candidate = entry as Record<string, unknown>;
const slug = candidate['slug'];
if (typeof slug !== 'string' || slug === '') {
throw new SectionError(`sections[${index}].slug must be a string`);
}
if (seen.has(slug)) throw new SectionError(`two Sections share the slug ${slug}`);
seen.add(slug);
const order = candidate['order'];
if (typeof order !== 'number' || !Number.isSafeInteger(order)) {
throw new SectionError(`sections[${index}].order must be a whole number`);
}
const label = cleanLabel(candidate['label'], `sections[${index}].label`);
return {
slug,
label,
shortLabel: typeof candidate['shortLabel'] === 'string' && candidate['shortLabel'] !== ''
? candidate['shortLabel']
: label,
note: typeof candidate['note'] === 'string' && candidate['note'] !== '' ? candidate['note'] : null,
order,
} satisfies SectionConfig;
});
return parsed.sort((a, b) => a.order - b.order);
}
export interface SectionStoreOptions {
dataDir: string;
/** Used only when the Site has no sections.json yet. */
seed: readonly SectionConfig[];
}
export class SectionStore {
readonly file: string;
private readonly seed: readonly SectionConfig[];
private sections: SectionConfig[] | null = null;
constructor(options: SectionStoreOptions) {
this.file = path.join(options.dataDir, 'sections.json');
this.seed = options.seed;
}
/** Reads the list once; every later access is an in-memory read. */
async load(): Promise<void> {
if (this.sections !== null) return;
try {
this.sections = parse(JSON.parse(await readFile(this.file, 'utf8')));
return;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw new SectionError(
`${this.file} could not be read: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
this.sections = [...this.seed].sort((a, b) => a.order - b.order);
if (this.sections.length > 0) await this.save(this.sections);
}
list(): readonly SectionConfig[] {
if (this.sections === null) throw new Error('SectionStore.load() has not been awaited');
return this.sections;
}
bySlug(slug: string): SectionConfig | undefined {
return this.list().find((section) => section.slug === slug);
}
has(slug: string): boolean {
return this.bySlug(slug) !== undefined;
}
async create(input: { label: string }): Promise<SectionConfig> {
const label = cleanLabel(input.label);
const slug = this.uniqueSlug(slugify(label));
if (slug === '') throw new SectionError(`"${label}" does not make a usable web address`);
const order = this.list().reduce((max, section) => Math.max(max, section.order), 0) + 1;
const created: SectionConfig = { slug, label, shortLabel: label, note: null, order };
await this.save([...this.list(), created]);
return created;
}
async rename(slug: string, input: { label: string }): Promise<SectionConfig> {
const label = cleanLabel(input.label);
const existing = this.require(slug);
const renamed: SectionConfig = { ...existing, label, shortLabel: label };
await this.save(this.list().map((section) => (section.slug === slug ? renamed : section)));
return renamed;
}
async move(slug: string, direction: 'up' | 'down'): Promise<void> {
this.require(slug);
const ordered = [...this.list()];
const index = ordered.findIndex((section) => section.slug === slug);
const target = direction === 'up' ? index - 1 : index + 1;
if (target < 0 || target >= ordered.length) return;
const [moved] = ordered.splice(index, 1);
ordered.splice(target, 0, moved!);
await this.save(ordered.map((section, position) => ({ ...section, order: position + 1 })));
}
async remove(slug: string): Promise<void> {
this.require(slug);
await this.save(
this.list()
.filter((section) => section.slug !== slug)
.map((section, position) => ({ ...section, order: position + 1 })),
);
}
private require(slug: string): SectionConfig {
const found = this.bySlug(slug);
if (found === undefined) throw new SectionError(`no such Section: ${slug}`, 404);
return found;
}
private uniqueSlug(base: string): string {
if (!RESERVED_SLUGS.has(base)) {
const taken = this.list().some((section) => section.slug === base);
if (!taken) return base;
}
for (let suffix = 2; suffix < 100; suffix += 1) {
const candidate = `${base}-${suffix}`;
if (!RESERVED_SLUGS.has(candidate) && !this.list().some((s) => s.slug === candidate)) {
return candidate;
}
}
throw new SectionError(`could not find a free web address for "${base}"`);
}
private async save(sections: readonly SectionConfig[]): Promise<void> {
const sorted = [...sections].sort((a, b) => a.order - b.order);
await writeJsonAtomic(this.file, { sections: sorted });
this.sections = sorted;
}
}
+4 -4
View File
@@ -88,8 +88,8 @@ export class StoreError extends Error {
export interface SetStoreOptions {
dataDir: string;
pipeline: ImagePipeline;
/** Configured Section slugs; a set may only belong to one of these. */
sections: readonly string[];
/** The Sections that exist; a Menu Set may only belong to one of these. */
sections: { has(slug: string): boolean };
maxPagesPerSet: number;
timeZone?: string | undefined;
now?: (() => Date) | undefined;
@@ -116,7 +116,7 @@ export class SetStore {
readonly paths: SitePaths;
private readonly pipeline: ImagePipeline;
private readonly sections: readonly string[];
private readonly sections: { has(slug: string): boolean };
private readonly maxPagesPerSet: number;
private readonly timeZone: string;
private readonly now: () => Date;
@@ -200,7 +200,7 @@ export class SetStore {
section: string,
input: { label: string; validUntil?: string | null },
): Promise<StoredSet> {
if (!this.sections.includes(section)) {
if (!this.sections.has(section)) {
throw new StoreError(`unknown Section: ${section}`);
}
const label = input.label.trim();
+3 -2
View File
@@ -15,7 +15,8 @@ export interface Problem {
export interface ValidateOptions {
dataDir: string;
sections: readonly string[];
/** Answers whether a slug still exists, so a removed Section is a problem. */
sections: { has(slug: string): boolean };
}
async function exists(target: string): Promise<boolean> {
@@ -75,7 +76,7 @@ export async function validateSite(options: ValidateOptions): Promise<Problem[]>
for (const set of sets) {
const where = `${set.state}/${set.manifest.section}/${set.slug ?? set.manifest.id}`;
if (!options.sections.includes(set.manifest.section)) {
if (!options.sections.has(set.manifest.section)) {
problems.push({ level: 'fatal', message: `${where}: Section is not in the Site config` });
}
if (set.manifest.section !== path.basename(path.dirname(set.dir))) {
+122
View File
@@ -751,6 +751,128 @@ describe('reordering', () => {
});
});
describe('managing categories', () => {
const form = (fields: Record<string, string>, referer?: string) => ({
headers: {
'content-type': 'application/x-www-form-urlencoded',
...(referer === undefined ? {} : { referer }),
},
payload: new URLSearchParams(fields).toString(),
});
async function token(): Promise<string> {
const dashboard = await app.inject({ method: 'GET', url: '/admin', cookies: cookies() });
const match = dashboard.body.match(/name="csrf" value="([^"]+)"/);
assert.ok(match, 'the dashboard carries no CSRF field');
return match[1]!;
}
const slugs = () => app.menu.sections.list().map((section) => section.slug);
it('lists the categories with the web address each one keeps', async () => {
const dashboard = await app.inject({ method: 'GET', url: '/admin', cookies: cookies() });
assert.match(dashboard.body, /Categories \(2\)/);
assert.match(dashboard.body, /\/food\//);
assert.match(dashboard.body, /\/bar\//);
});
it('adds a category, slugged from its name, and customers do not see it until it has a menu', async () => {
const response = await app.inject({
method: 'POST',
url: '/admin/sections',
cookies: cookies(),
...form({ csrf: await token(), label: 'Straight Drinks' }),
headers: { 'content-type': 'application/x-www-form-urlencoded', referer: '/admin' },
});
assert.equal(response.statusCode, 303);
assert.match(String(response.headers['location']), /notice=/);
assert.deepEqual(slugs(), ['food', 'bar', 'straight-drinks']);
const landing = await app.inject({ method: 'GET', url: '/' });
assert.doesNotMatch(landing.body, /straight-drinks/);
assert.equal((await app.inject({ method: 'GET', url: '/straight-drinks/' })).statusCode, 404);
});
it('renames a category without moving the web address', async () => {
const referer = '/admin';
const response = await app.inject({
method: 'POST',
url: '/admin/sections/bar/rename',
cookies: cookies(),
...form({ csrf: await token(), label: 'Bar & Cocktails' }, referer),
});
assert.equal(response.statusCode, 303);
assert.equal(app.menu.sections.bySlug('bar')?.label, 'Bar & Cocktails');
assert.equal(app.menu.sections.bySlug('bar-and-cocktails'), undefined);
});
it('reorders categories', async () => {
assert.deepEqual(slugs(), ['food', 'bar']);
const response = await app.inject({
method: 'POST',
url: '/admin/sections/bar/move',
cookies: cookies(),
...form({ csrf: await token(), direction: 'up' }, '/admin'),
});
assert.equal(response.statusCode, 303);
assert.deepEqual(slugs(), ['bar', 'food']);
const dashboard = await app.inject({ method: 'GET', url: '/admin', cookies: cookies() });
assert.ok(
dashboard.body.indexOf('value="Bar"') < dashboard.body.indexOf('value="Food"'),
'the manager lists them in the new order',
);
});
it('refuses to remove a category that still holds menus, then allows it once empty', async () => {
const { id } = await newSet('bar', 'Bar Menu');
await upload(id, 'one.jpg');
const blocked = await app.inject({
method: 'POST',
url: '/admin/sections/bar/delete',
cookies: cookies(),
...form({ csrf: await token() }, '/admin'),
});
assert.equal(blocked.statusCode, 303);
const blockedUrl = String(blocked.headers['location']);
assert.match(blockedUrl, /error=/);
assert.deepEqual(slugs(), ['food', 'bar']);
const dashboard = await app.inject({ method: 'GET', url: blockedUrl, cookies: cookies() });
assert.match(dashboard.body, /still holds 1 menu set/);
await app.inject({
method: 'POST',
url: `/admin/sets/${id}/delete`,
cookies: cookies(),
headers: { 'x-csrf': csrf },
payload: { confirm: 'yes' },
});
const removed = await app.inject({
method: 'POST',
url: '/admin/sections/bar/delete',
cookies: cookies(),
...form({ csrf: await token() }, '/admin'),
});
assert.match(String(removed.headers['location']), /notice=/);
assert.deepEqual(slugs(), ['food']);
});
it('needs a token to change the categories', async () => {
const response = await app.inject({
method: 'POST',
url: '/admin/sections',
cookies: cookies(),
headers: { 'content-type': 'application/x-www-form-urlencoded' },
payload: 'label=Desserts',
});
assert.equal(response.statusCode, 403);
assert.deepEqual(slugs(), ['food', 'bar']);
});
});
describe('previewing a Draft', () => {
it('renders the customer page with a banner, and serves its media to the session', async () => {
const { id } = await newSet('bar', 'Bar Menu', '2026-06-26');
+1 -1
View File
@@ -28,7 +28,7 @@ async function publishedSetWithPages(count = 2) {
}
function validate() {
return validateSite({ dataDir, sections: [...SECTIONS] });
return validateSite({ dataDir, sections: { has: (slug) => (SECTIONS as readonly string[]).includes(slug) } });
}
describe('repairSite', () => {
+32 -2
View File
@@ -89,9 +89,11 @@ describe('the landing page', () => {
assert.equal(response.statusCode, 200);
assert.match(response.body, /Hops n Grains/);
assert.match(response.body, /Handcrafted Microbrewery/);
assert.match(response.body, /<span class="row__label">Bar<\/span>/);
assert.match(response.body, /class="shelf" href="\/bar\/"/);
assert.match(response.body, /<span class="shelf__name">Bar<\/span>/);
assert.match(response.body, /2 pages/);
assert.doesNotMatch(response.body, /row__label">Wine/);
assert.match(response.body, /class="shelf__peek"/, 'the shelf shows a sliver of the menu paper');
assert.doesNotMatch(response.body, /shelf__name">Wine/);
});
it('escapes a label that tries to inject markup', async () => {
@@ -128,6 +130,9 @@ describe('a Section page', () => {
assert.doesNotMatch(body, /Older menus/);
assert.doesNotMatch(body, /This is an older menu/);
assert.match(body, /src="\/assets\/app\.js"/);
assert.match(body, /class="switcher"/, 'the bar names the menu and opens the switcher');
assert.match(body, /<span class="switcher__label">Bar<\/span>/);
assert.match(body, /id="menu-sheet"/, 'the sheet exists for the script to open');
});
it('serves both the trailing-slash and bare URLs', async () => {
@@ -142,6 +147,31 @@ describe('a Section page', () => {
});
});
describe('the menu switcher', () => {
it('lists every Section that has a menu, in category order, marking the one being read', async () => {
await seedPublishedBar();
const store = app.menu.store;
const food = await store.create('food', { label: 'Food Menu' });
await store.addPage(food.id, { data: pngBytes('food'), originalName: 'f.jpg' });
await store.publish(food.id);
const page = await app.inject({ method: 'GET', url: '/bar/' });
const listed = [...page.body.matchAll(/class="sheet__item[^"]*"\s+href="\/([a-z-]+)\//g)].map(
(match) => match[1],
);
assert.deepEqual(listed, ['food', 'bar']);
assert.equal((page.body.match(/sheet__item is-current/g) ?? []).length, 1);
assert.match(page.body, />Showing</);
});
it('leaves a category with no menu out until it has one', async () => {
await seedPublishedBar();
const page = await app.inject({ method: 'GET', url: '/bar/' });
assert.doesNotMatch(page.body, /sheet__name">Wine/);
assert.equal((await app.inject({ method: 'GET', url: '/wine/' })).statusCode, 404);
});
});
describe('an archived Menu Set', () => {
it('is reachable at its date URL, marked as older, and does not list itself', async () => {
const store = app.menu.store;
+158
View File
@@ -0,0 +1,158 @@
import { strict as assert } from 'node:assert';
import { readFile, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { afterEach, beforeEach, describe, it } from 'node:test';
import { SectionError, SectionStore, slugify } from '../src/storage/sections.ts';
import { tempDir } from './support/harness.ts';
const SEED = [
{ slug: 'food', label: 'Food', shortLabel: 'Food', note: null, order: 1 },
{ slug: 'bar', label: 'Bar', shortLabel: 'Bar', note: null, order: 2 },
];
let dataDir: string;
beforeEach(async () => {
dataDir = await tempDir('sections');
});
afterEach(async () => {
await rm(dataDir, { recursive: true, force: true });
});
async function loaded(seed = SEED) {
const store = new SectionStore({ dataDir, seed });
await store.load();
return store;
}
describe('slugify', () => {
it('turns the labels people type into stable web addresses', () => {
assert.equal(slugify('Straight Drinks'), 'straight-drinks');
assert.equal(slugify("What's On"), 'whats-on');
assert.equal(slugify('Desserts & Coffee'), 'desserts-coffee');
assert.equal(slugify(' Bar '), 'bar');
assert.equal(slugify('Wine / Beer'), 'wine-beer');
assert.equal(slugify('----'), '');
});
});
describe('SectionStore', () => {
it('seeds from the config the first time and writes the file', async () => {
const store = await loaded();
assert.deepEqual(
store.list().map((section) => section.slug),
['food', 'bar'],
);
const file = JSON.parse(await readFile(path.join(dataDir, 'sections.json'), 'utf8'));
assert.equal(file.sections.length, 2);
});
it('keeps the file as the source of truth afterwards, ignoring the seed', async () => {
await loaded();
const second = new SectionStore({ dataDir, seed: [] });
await second.load();
assert.deepEqual(
second.list().map((section) => section.slug),
['food', 'bar'],
);
});
it('refuses to serve an unreadable file rather than silently reseeding', async () => {
await writeFile(path.join(dataDir, 'sections.json'), '{ not json');
const store = new SectionStore({ dataDir, seed: SEED });
await assert.rejects(() => store.load(), SectionError);
});
it('complains if it is read before loading', () => {
const store = new SectionStore({ dataDir, seed: SEED });
assert.throws(() => store.list(), /load\(\)/);
});
it('adds a category at the end, with a slug from the name', async () => {
const store = await loaded();
const created = await store.create({ label: 'Straight Drinks' });
assert.equal(created.slug, 'straight-drinks');
assert.equal(created.order, 3);
assert.deepEqual(
store.list().map((section) => section.label),
['Food', 'Bar', 'Straight Drinks'],
);
});
it('makes a slug unique, and steers around the names the app reserves', async () => {
const store = await loaded();
await store.create({ label: 'Desserts' });
assert.equal((await store.create({ label: 'Desserts' })).slug, 'desserts-2');
assert.equal((await store.create({ label: 'Admin' })).slug, 'admin-2');
assert.equal((await store.create({ label: 'Media' })).slug, 'media-2');
});
it('rejects an empty name and a name that makes no web address', async () => {
const store = await loaded();
await assert.rejects(() => store.create({ label: ' ' }), SectionError);
await assert.rejects(() => store.create({ label: '!!!' }), SectionError);
});
it('renames without moving the web address customers already have', async () => {
const store = await loaded();
const renamed = await store.rename('bar', { label: 'Bar & Cocktails' });
assert.equal(renamed.label, 'Bar & Cocktails');
assert.equal(renamed.slug, 'bar');
assert.equal(store.bySlug('bar')?.label, 'Bar & Cocktails');
});
it('moves a category and renumbers the rest', async () => {
const store = await loaded();
await store.move('bar', 'up');
assert.deepEqual(
store.list().map((section) => section.slug),
['bar', 'food'],
);
assert.deepEqual(
store.list().map((section) => section.order),
[1, 2],
);
// Past either end is a no-op, not an error.
await store.move('bar', 'up');
assert.deepEqual(
store.list().map((section) => section.slug),
['bar', 'food'],
);
});
it('removes a category and closes the gap in the order', async () => {
const store = await loaded();
await store.create({ label: 'Wine' });
await store.remove('bar');
assert.deepEqual(
store.list().map((section) => [section.slug, section.order]),
[
['food', 1],
['wine', 2],
],
);
});
it('reports an unknown slug rather than inventing one', async () => {
const store = await loaded();
await assert.rejects(() => store.rename('pizza', { label: 'Pizza' }), SectionError);
await assert.rejects(() => store.remove('pizza'), SectionError);
});
it('survives add, rename, move and remove being read by a second instance', async () => {
const store = await loaded();
const created = await store.create({ label: 'Desserts' });
await store.rename(created.slug, { label: 'Puddings' });
await store.move(created.slug, 'up');
const reopened = new SectionStore({ dataDir, seed: [] });
await reopened.load();
assert.deepEqual(
reopened.list().map((section) => section.label),
['Food', 'Puddings', 'Bar'],
);
});
});
+1 -1
View File
@@ -53,7 +53,7 @@ export function makeStore(
const store = new SetStore({
dataDir,
pipeline,
sections: [...SECTIONS],
sections: { has: (slug: string) => (SECTIONS as readonly string[]).includes(slug) },
maxPagesPerSet: options.maxPagesPerSet ?? 40,
timeZone: 'Asia/Kolkata',
now: clock.now,
+1 -1
View File
@@ -18,7 +18,7 @@ afterEach(async () => {
});
function validate() {
return validateSite({ dataDir, sections: [...SECTIONS] });
return validateSite({ dataDir, sections: { has: (slug) => (SECTIONS as readonly string[]).includes(slug) } });
}
describe('validateSite', () => {