/** * 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, ): 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`
`; } function renderSheet(config: SiteConfig, entries: readonly SheetEntry[]): Html { return html``; } function renderPages(section: SectionConfig, pages: readonly PageRecord[]): Html { const total = pages.length; return html`
${join( pages.map((page, index) => { const urls = mediaUrls(page); const number = index + 1; return html`
${section.label}, page ${number} of ${total}
`; }), )}
`; } function renderPageIndex(total: number): Html { return html`
${pageCountLabel(total)}
    ${join( Array.from({ length: total }, (_, index) => index + 1).map( (number) => html`
  1. ${number}
  2. `, ), )}
`; } function renderOlderMenus(section: SectionConfig, archives: readonly StoredSet[], currentHref: string): Html { if (archives.length === 0) return Html.raw(''); return html`
Older menus

Back to the current menu

`; } 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`

The menus are being updated. Please ask a member of staff.

` : html``; return renderDocument({ config, title: null, bodyClass: 'landing-page', description: config.brand.tagline, noindex: false, styles: [], scripts: [], body: html`

${config.brand.name}

${config.brand.tagline === null ? '' : html`

${config.brand.tagline}

`}
${rows}
`, }); } 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`

Preview of a Draft — customers cannot see this yet. Back to the editor

` : ''}

${set.manifest.label} ${setDateLabel(set.manifest)}

${input.current || input.preview ? '' : html`

This is an older menu. See the current menu.

`} ${renderPages(section, set.manifest.pages)} `; 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``; return renderDocument({ config, title: 'Not found', bodyClass: 'landing-page', description: null, noindex: true, styles: [], scripts: [], body: html`

${config.brand.name}

That menu does not exist.

${live.length === 0 ? '' : rows}
`, }); }