Three things that needed a rebuild and a redeploy, or had no way in at all, now have a Site identity panel in the admin: the restaurant's name and tagline, an SVG icon upload, and a switch for whether a menu says when it was published. They live in data/site.json and data/brand/icon.svg, extending ADR 0007: what the owner changes is content, and the config is the seed plus the theme, which still needs a rebuild. An uploaded SVG is a document that would run with this origin's authority, so it is reduced to shapes on the way in and served with default-src 'none'. The archive list keeps its dates even when the switch is off, because those dates are how one old menu is told from another. Also fixes the live check's own trap: the app caches identity in memory, so the tests drive it through the app rather than a second store instance, which would be a second writer.
292 lines
9.5 KiB
TypeScript
292 lines
9.5 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="26" height="26" />
|
|
<span class="bar__name">${config.brand.name}</span>
|
|
</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="leader" aria-hidden="true"></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>
|
|
${config.display.showDates
|
|
? html`<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>`,
|
|
});
|
|
}
|