Serve the customer-facing menus
Landing page, Section pages and archived sets rendered per request from the store, with the approved design: hand-written CSS, self-hosted Archivo, a copper pour line in the Section rail, an admin-only Thumbnail and a text page index. Media is keyed by Page id so URLs survive publishing, and a Draft's media needs a session. Includes the scrypt admin auth and the login throttle the media rule needs.
This commit is contained in:
1 parent
edea83228c
commit
9cb4021229
23 files changed
+2320
-6
No files matched your search
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* 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 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';
|
||||
}
|
||||
|
||||
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>`;
|
||||
}
|
||||
|
||||
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 LandingInput {
|
||||
config: SiteConfig;
|
||||
/** Sections that have a Current Menu Set, in config order. */
|
||||
live: readonly { section: SectionConfig; set: StoredSet }[];
|
||||
}
|
||||
|
||||
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="rows" 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>
|
||||
</a>`,
|
||||
),
|
||||
)}
|
||||
</nav>`;
|
||||
|
||||
return renderDocument({
|
||||
config,
|
||||
title: null,
|
||||
bodyClass: 'landing-page',
|
||||
description: config.brand.tagline,
|
||||
noindex: false,
|
||||
script: false,
|
||||
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;
|
||||
/** Section slugs that have a Current Menu Set, for the rail. */
|
||||
published: ReadonlySet<string>;
|
||||
current: boolean;
|
||||
archives: readonly StoredSet[];
|
||||
}
|
||||
|
||||
export function renderSectionPage(input: SectionInput): Html {
|
||||
const { config, section, set } = input;
|
||||
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>
|
||||
<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 ? '' : 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)} ${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,
|
||||
script: true,
|
||||
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,
|
||||
script: false,
|
||||
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>`,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user