Files
menu/public/app.js
T
tanshu c31b73d553 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.
2026-09-22 22:24:53 +05:30

169 lines
5.4 KiB
JavaScript

/* Progressive enhancement only: the pour line, the page counter, and
tap-to-zoom. Without this file every menu is still readable and every
Page opens as a plain link. */
(function () {
'use strict';
var reducesMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var pages = document.querySelector('[data-pages]');
var fill = document.querySelector('[data-pour]');
var counter = document.querySelector('[data-counter]');
var counterCurrent = counter && counter.querySelector('[data-counter-current]');
var figures = pages ? Array.prototype.slice.call(pages.querySelectorAll('[data-page]')) : [];
var currentPage = 1;
function updatePour() {
if (!pages || !fill) return;
var rect = pages.getBoundingClientRect();
var travel = rect.height - window.innerHeight;
if (travel <= 0) {
fill.style.width = '100%';
return;
}
var progress = Math.min(Math.max(-rect.top, 0), travel) / travel;
fill.style.width = (progress * 100).toFixed(1) + '%';
}
function updateCounter() {
if (!counter || figures.length === 0) return;
var middle = window.innerHeight / 2;
var best = 1;
var bestDistance = Infinity;
figures.forEach(function (figure, index) {
var rect = figure.getBoundingClientRect();
var distance = Math.abs(rect.top + rect.height / 2 - middle);
if (distance < bestDistance) {
bestDistance = distance;
best = index + 1;
}
});
if (best !== currentPage) {
currentPage = best;
if (counterCurrent) counterCurrent.textContent = String(best);
}
}
function onScroll() {
updatePour();
updateCounter();
}
if (counter) counter.hidden = false;
if (figures.length > 0) {
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
onScroll();
}
if (counter) {
counter.addEventListener('click', function () {
var fold = document.querySelector('.fold--index');
if (!fold) return;
fold.open = true;
fold.scrollIntoView({ behavior: reducesMotion ? 'auto' : 'smooth', block: 'center' });
var first = fold.querySelector('a');
if (first) first.focus({ preventScroll: true });
});
}
var overlay = null;
function closeOverlay() {
if (!overlay) return;
overlay.remove();
overlay = null;
document.body.style.overflow = '';
document.removeEventListener('keydown', onKeydown);
}
function onKeydown(event) {
if (event.key === 'Escape') closeOverlay();
}
function openOverlay(href, alt) {
overlay = document.createElement('div');
overlay.className = 'zoom';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', alt);
var close = document.createElement('button');
close.type = 'button';
close.className = 'zoom__close';
close.textContent = 'Close';
var image = document.createElement('img');
image.src = href;
image.alt = alt;
overlay.appendChild(close);
overlay.appendChild(image);
overlay.addEventListener('click', function (event) {
if (event.target === overlay || event.target === close) closeOverlay();
});
document.addEventListener('keydown', onKeydown);
document.body.appendChild(overlay);
document.body.style.overflow = 'hidden';
close.focus();
}
Array.prototype.forEach.call(document.querySelectorAll('[data-zoom]'), function (link) {
link.addEventListener('click', function (event) {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) {
return;
}
var image = link.querySelector('img');
event.preventDefault();
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();
});
}
})();