Files
menu/src/render/pages.ts
T
tanshu c5c0b7d005 Put the other menus in the top bar
The bar hid them behind a switcher and a sheet. It now names every menu that
exists, which is what was asked for; it scrolls sideways when the categories
outgrow the width, and the current one is the only filled item. Without the
script it is simply a row of links, so nothing is lost when JavaScript is off.
2026-09-22 22:35:38 +05:30

288 lines
9.3 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';
}
/**
* The bar names every menu that exists, so the other pages are one tap away
* rather than behind a control. It scrolls sideways when the categories outgrow
* the width, which is why these are plain links and not tabs or pills.
*/
function renderBar(config: SiteConfig, currentSlug: string, menu: readonly MenuLink[]): 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>
<nav class="bar__nav" aria-label="Menus">
${join(
menu.map(
(entry) => html`<a
class="bar__link${entry.section.slug === currentSlug ? ' is-current' : ''}"
href="/${entry.section.slug}/"
${entry.section.slug === currentSlug ? html`aria-current="page"` : ''}
>${entry.section.shortLabel}</a
>`,
),
)}
</nav>
<div class="pour" aria-hidden="true"><span class="pour__fill" data-pour></span></div>
</header>`;
}
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.slug, input.menu)}
${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>`,
});
}