From c31b73d553624e8abc775c16f9899a9468673505 Mon Sep 17 00:00:00 2001 From: Amritanshu Date: Tue, 22 Sep 2026 22:24:53 +0530 Subject: [PATCH] Let the owner manage categories, and give the menus a shelf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Categories were baked into config/.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. --- CONTEXT.md | 6 +- README.md | 5 +- .../0005-sections-come-from-configuration.md | 2 + docs/adr/0007-categories-are-content.md | 7 + docs/operating.md | 15 +- docs/plan.md | 12 +- public/admin.css | 57 +++ public/app.js | 48 +++ public/styles.css | 360 +++++++++++++----- scripts/seed-dev.ts | 13 +- src/cli/verify.ts | 9 +- src/render/admin.ts | 54 ++- src/render/pages.ts | 130 +++++-- src/routes/admin.ts | 114 +++++- src/routes/public.ts | 34 +- src/server.ts | 25 +- src/storage/sections.ts | 191 ++++++++++ src/storage/sets.ts | 8 +- src/storage/validate.ts | 5 +- test/admin.test.ts | 122 ++++++ test/repair.test.ts | 2 +- test/routes.test.ts | 34 +- test/sections.test.ts | 158 ++++++++ test/support/harness.ts | 2 +- test/validate.test.ts | 2 +- 25 files changed, 1225 insertions(+), 190 deletions(-) create mode 100644 docs/adr/0007-categories-are-content.md create mode 100644 src/storage/sections.ts create mode 100644 test/sections.test.ts diff --git a/CONTEXT.md b/CONTEXT.md index 954ebd5..dba1454 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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. diff --git a/README.md b/README.md index 0218117..c590466 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/adr/0005-sections-come-from-configuration.md b/docs/adr/0005-sections-come-from-configuration.md index 20023c1..a73e9c7 100644 --- a/docs/adr/0005-sections-come-from-configuration.md +++ b/docs/adr/0005-sections-come-from-configuration.md @@ -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. diff --git a/docs/adr/0007-categories-are-content.md b/docs/adr/0007-categories-are-content.md new file mode 100644 index 0000000..cbcd830 --- /dev/null +++ b/docs/adr/0007-categories-are-content.md @@ -0,0 +1,7 @@ +# Categories are content, not configuration + +Sections moved out of `config/.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). diff --git a/docs/operating.md b/docs/operating.md index 334c604..2edcc76 100644 --- a/docs/operating.md +++ b/docs/operating.md @@ -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./data` — `originals/`, `drafts/`, `published/` | | Admin | `https:///admin` | | The password | `/var/lib/menu-app./site.env` (mode 600, never in git) | -| Sections and branding | `config/.site.json` in this repo, baked into the image | +| Categories | `data/sections.json`, edited in the admin | +| Brand and theme | `config/.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.json`, rebuild and -push (`./deploy.sh `), 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.json`, rebuild and push +(`./deploy.sh `), 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: diff --git a/docs/plan.md b/docs/plan.md index 4a0a5fc..5d9dda2 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -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 /
/` | Current Menu Set | | `GET /
//` | Archived Menu Set (unlisted, still reachable) | | `GET /
/#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.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.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:`, 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.`, 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. diff --git a/public/admin.css b/public/admin.css index 6a38477..da0a8f7 100644 --- a/public/admin.css +++ b/public/admin.css @@ -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; +} diff --git a/public/app.js b/public/app.js index 5d4170e..930a78e 100644 --- a/public/app.js +++ b/public/app.js @@ -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(); + }); + } })(); diff --git a/public/styles.css b/public/styles.css index ec2786a..87225b5 100644 --- a/public/styles.css +++ b/public/styles.css @@ -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 { diff --git a/scripts/seed-dev.ts b/scripts/seed-dev.ts index 8ef6b36..a771d4d 100644 --- a/scripts/seed-dev.ts +++ b/scripts/seed-dev.ts @@ -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) { diff --git a/src/cli/verify.ts b/src/cli/verify.ts index aad552b..1221d4d 100644 --- a/src/cli/verify.ts +++ b/src/cli/verify.ts @@ -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}`); diff --git a/src/render/admin.ts b/src/render/admin.ts index c55fb58..09506c2 100644 --- a/src/render/admin.ts +++ b/src/render/admin.ts @@ -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; csrf: string; notice: string | null; + error: string | null; +} + +function renderCategories(input: DashboardInput): Html { + const rows = input.sections.map( + (section, index) => html`
  • +
    + + + +
    + /${section.slug}/ + +
    + + + +
    +
    + + + +
    +
    + + +
    +
    +
  • `, + ); + + return html`
    + Categories (${input.sections.length}) +

    + 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. +

    +
      ${join(rows)}
    +
    + + ${labelled('New category', html``)} + +
    +
    `; } 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 { ${input.notice === null ? '' : html`

    ${input.notice}

    `} + ${input.error === null ? '' : html`

    ${input.error}

    `} ${blocks} + ${renderCategories(input)} `, }); } diff --git a/src/render/pages.ts b/src/render/pages.ts index 4d3fbb8..f79cdf2 100644 --- a/src/render/pages.ts +++ b/src/render/pages.ts @@ -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, +): 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): Html { - return html``; +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`
    + + + + + +
    `; +} + +function renderSheet(config: SiteConfig, entries: readonly SheetEntry[]): Html { + return html``; } function renderPages(section: SectionConfig, pages: readonly PageRecord[]): Html { @@ -125,10 +180,17 @@ function renderOlderMenus(section: SectionConfig, archives: readonly StoredSet[] `; } +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`

    The menus are being updated. Please ask a member of staff.

    ` - : html`