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.
327 lines
11 KiB
TypeScript
327 lines
11 KiB
TypeScript
/**
|
|
* The customer-facing pages. Server-rendered HTML, one small script for the
|
|
* pour line, the page counter and tap-to-zoom; every one of those degrades to
|
|
* a plain link without JavaScript.
|
|
*/
|
|
|
|
import { html, Html, join } from './html.ts';
|
|
import { renderDocument } from './layout.ts';
|
|
import type { SectionConfig, SiteConfig } from '../config.ts';
|
|
import type { PageRecord, SetManifest } from '../storage/manifest.ts';
|
|
import type { StoredSet } from '../storage/sets.ts';
|
|
|
|
export interface MediaUrls {
|
|
webp: string;
|
|
jpeg: string;
|
|
thumb: string;
|
|
/** The Viewing image at full width, for the zoom overlay and no-JS links. */
|
|
view: string;
|
|
}
|
|
|
|
export function mediaUrls(page: PageRecord): MediaUrls {
|
|
const webp = `/media/${page.id}.${page.hash}.webp`;
|
|
const jpeg = `/media/${page.id}.${page.hash}.jpg`;
|
|
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`;
|
|
}
|
|
|
|
const DATE_FORMAT = new Intl.DateTimeFormat('en-GB', {
|
|
day: 'numeric',
|
|
month: 'long',
|
|
year: 'numeric',
|
|
timeZone: 'UTC',
|
|
});
|
|
|
|
export function formatCalendarDate(value: string): string {
|
|
return DATE_FORMAT.format(new Date(`${value}T00:00:00Z`));
|
|
}
|
|
|
|
export function setDateLabel(manifest: SetManifest): string {
|
|
if (manifest.validUntil !== null) return `until ${formatCalendarDate(manifest.validUntil)}`;
|
|
if (manifest.publishedAt !== null) {
|
|
return `published ${DATE_FORMAT.format(new Date(manifest.publishedAt))}`;
|
|
}
|
|
return 'not published';
|
|
}
|
|
|
|
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 {
|
|
const total = pages.length;
|
|
return html`<main class="pages" data-pages>
|
|
${join(
|
|
pages.map((page, index) => {
|
|
const urls = mediaUrls(page);
|
|
const number = index + 1;
|
|
return html`<figure class="page" id="page-${number}" data-page="${number}">
|
|
<a class="page__zoom" href="${urls.view}" data-zoom>
|
|
<picture>
|
|
<source type="image/webp" srcset="${urls.webp}" />
|
|
<img
|
|
src="${urls.jpeg}"
|
|
width="${page.width}"
|
|
height="${page.height}"
|
|
alt="${section.label}, page ${number} of ${total}"
|
|
loading="${number <= 1 ? 'eager' : 'lazy'}"
|
|
decoding="async"
|
|
/>
|
|
</picture>
|
|
</a>
|
|
</figure>`;
|
|
}),
|
|
)}
|
|
</main>`;
|
|
}
|
|
|
|
function renderPageIndex(total: number): Html {
|
|
return html`<details class="fold fold--index">
|
|
<summary>${pageCountLabel(total)}</summary>
|
|
<ol class="index">
|
|
${join(
|
|
Array.from({ length: total }, (_, index) => index + 1).map(
|
|
(number) => html`<li><a href="#page-${number}">${number}</a></li>`,
|
|
),
|
|
)}
|
|
</ol>
|
|
</details>`;
|
|
}
|
|
|
|
function renderOlderMenus(section: SectionConfig, archives: readonly StoredSet[], currentHref: string): Html {
|
|
if (archives.length === 0) return Html.raw('');
|
|
return html`<details class="fold fold--older">
|
|
<summary>Older menus</summary>
|
|
<ul class="older">
|
|
${join(
|
|
archives.map(
|
|
(set) => html`<li>
|
|
<a href="/${section.slug}/${set.slug}/">
|
|
<span class="older__label">${set.manifest.label}</span>
|
|
<span class="older__date">${setDateLabel(set.manifest)}</span>
|
|
</a>
|
|
</li>`,
|
|
),
|
|
)}
|
|
</ul>
|
|
<p class="fold__back"><a href="${currentHref}">Back to the current menu</a></p>
|
|
</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 the order customers should see. */
|
|
live: readonly MenuLink[];
|
|
}
|
|
|
|
export function renderLanding(input: LandingInput): Html {
|
|
const { config } = input;
|
|
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="shelves" aria-label="Menus">
|
|
${join(
|
|
input.live.map(
|
|
(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>`,
|
|
),
|
|
)}
|
|
</nav>`;
|
|
|
|
return renderDocument({
|
|
config,
|
|
title: null,
|
|
bodyClass: 'landing-page',
|
|
description: config.brand.tagline,
|
|
noindex: false,
|
|
styles: [],
|
|
scripts: [],
|
|
body: html`<main class="landing">
|
|
<header class="masthead">
|
|
<h1 class="wordmark">${config.brand.name}</h1>
|
|
${config.brand.tagline === null
|
|
? ''
|
|
: html`<p class="masthead__tagline">${config.brand.tagline}</p>`}
|
|
</header>
|
|
${rows}
|
|
</main>`,
|
|
});
|
|
}
|
|
|
|
export interface SectionInput {
|
|
config: SiteConfig;
|
|
section: SectionConfig;
|
|
set: StoredSet;
|
|
/** 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. */
|
|
preview: boolean;
|
|
}
|
|
|
|
export function renderSectionPage(input: SectionInput): Html {
|
|
const { config, section, set } = input;
|
|
const currentHref = `/${section.slug}/`;
|
|
const total = set.manifest.pages.length;
|
|
|
|
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.
|
|
<a href="/admin/sets/${set.id}">Back to the editor</a>
|
|
</p>`
|
|
: ''}
|
|
<h2 class="section-heading">
|
|
<span class="section-heading__label">${set.manifest.label}</span>
|
|
<span class="section-heading__date">${setDateLabel(set.manifest)}</span>
|
|
</h2>
|
|
${input.current || input.preview
|
|
? ''
|
|
: html`<p class="notice">This is an older menu. <a href="${currentHref}">See the current menu</a>.</p>`}
|
|
${renderPages(section, set.manifest.pages)}
|
|
<footer class="foot">
|
|
${renderPageIndex(total)}
|
|
${input.preview ? '' : renderOlderMenus(section, input.archives, currentHref)}
|
|
</footer>
|
|
<button class="counter" type="button" data-counter hidden>
|
|
<span data-counter-current>1</span><span class="counter__total">/ ${total}</span>
|
|
</button>`;
|
|
|
|
return renderDocument({
|
|
config,
|
|
title: `${set.manifest.label} · ${section.label}`,
|
|
bodyClass: 'section-page',
|
|
description: config.brand.tagline,
|
|
noindex: !input.current,
|
|
styles: [],
|
|
scripts: ['/assets/app.js'],
|
|
body,
|
|
});
|
|
}
|
|
|
|
export function renderNotFound(config: SiteConfig, live: readonly SectionConfig[]): Html {
|
|
const rows = html`<nav class="rows" aria-label="Menus">
|
|
${join(
|
|
live.map(
|
|
(section) => html`<a class="row" href="/${section.slug}/">
|
|
<span class="row__label">${section.label}</span>
|
|
<span class="row__meta">View menu</span>
|
|
</a>`,
|
|
),
|
|
)}
|
|
</nav>`;
|
|
|
|
return renderDocument({
|
|
config,
|
|
title: 'Not found',
|
|
bodyClass: 'landing-page',
|
|
description: null,
|
|
noindex: true,
|
|
styles: [],
|
|
scripts: [],
|
|
body: html`<main class="landing">
|
|
<header class="masthead">
|
|
<h1 class="wordmark">${config.brand.name}</h1>
|
|
</header>
|
|
<p class="empty">That menu does not exist.</p>
|
|
${live.length === 0 ? '' : rows}
|
|
</main>`,
|
|
});
|
|
}
|