Let the owner manage categories, and give the menus a shelf
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.
This commit is contained in:
1 parent
7bf67cc299
commit
c31b73d553
25 files changed
+1225
-190
No files matched your search
+5
-4
@@ -6,13 +6,14 @@
|
||||
*/
|
||||
|
||||
import { hasFatal, validateSite } from '../storage/validate.ts';
|
||||
import { SectionStore } from '../storage/sections.ts';
|
||||
import { loadCliSite } from './site.ts';
|
||||
|
||||
const { config, dataDir } = await loadCliSite();
|
||||
const problems = await validateSite({
|
||||
dataDir,
|
||||
sections: config.sections.map((section) => section.slug),
|
||||
});
|
||||
const sections = new SectionStore({ dataDir, seed: config.sections });
|
||||
await sections.load();
|
||||
|
||||
const problems = await validateSite({ dataDir, sections });
|
||||
|
||||
if (problems.length === 0) {
|
||||
console.log(`ok: ${dataDir}`);
|
||||
|
||||
+53
-1
@@ -88,17 +88,67 @@ function setRow(section: SectionConfig, set: StoredSet, kind: 'current' | 'draft
|
||||
|
||||
export interface DashboardInput {
|
||||
config: SiteConfig;
|
||||
/** Every Section, in order: the Site's own categories, not configuration. */
|
||||
sections: readonly SectionConfig[];
|
||||
sets: readonly StoredSet[];
|
||||
current: ReadonlyMap<string, StoredSet>;
|
||||
csrf: string;
|
||||
notice: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function renderCategories(input: DashboardInput): Html {
|
||||
const rows = input.sections.map(
|
||||
(section, index) => html`<li class="catrow">
|
||||
<form class="catrow__name" method="post" action="/admin/sections/${section.slug}/rename">
|
||||
<input type="hidden" name="csrf" value="${input.csrf}" />
|
||||
<input type="text" name="label" value="${section.label}" aria-label="Name shown for ${section.label}" required />
|
||||
<button class="button button--icon" type="submit">Save</button>
|
||||
</form>
|
||||
<span class="catrow__url">/${section.slug}/</span>
|
||||
<span class="catrow__actions">
|
||||
<form method="post" action="/admin/sections/${section.slug}/move">
|
||||
<input type="hidden" name="csrf" value="${input.csrf}" />
|
||||
<input type="hidden" name="direction" value="up" />
|
||||
<button class="button button--icon" type="submit" title="Move up" ${index === 0 ? 'disabled' : ''}>↑</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/sections/${section.slug}/move">
|
||||
<input type="hidden" name="csrf" value="${input.csrf}" />
|
||||
<input type="hidden" name="direction" value="down" />
|
||||
<button class="button button--icon" type="submit" title="Move down" ${index === input.sections.length - 1 ? 'disabled' : ''}>↓</button>
|
||||
</form>
|
||||
<form
|
||||
method="post"
|
||||
action="/admin/sections/${section.slug}/delete"
|
||||
data-confirm="Remove the ${section.label} category?"
|
||||
>
|
||||
<input type="hidden" name="csrf" value="${input.csrf}" />
|
||||
<button class="button button--icon button--danger" type="submit" title="Remove category">✕</button>
|
||||
</form>
|
||||
</span>
|
||||
</li>`,
|
||||
);
|
||||
|
||||
return html`<details class="fold fold--categories">
|
||||
<summary>Categories (${input.sections.length})</summary>
|
||||
<p class="muted">
|
||||
The order here is the order customers see. A name can change at any time; the web address
|
||||
beside it cannot, because that is what a table card or QR code points at.
|
||||
</p>
|
||||
<ul class="catlist">${join(rows)}</ul>
|
||||
<form class="newset" method="post" action="/admin/sections">
|
||||
<input type="hidden" name="csrf" value="${input.csrf}" />
|
||||
${labelled('New category', html`<input type="text" name="label" placeholder="Desserts" required />`)}
|
||||
<button class="button" type="submit">Add category</button>
|
||||
</form>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
export function renderDashboard(input: DashboardInput): Html {
|
||||
const { config } = input;
|
||||
const bySection = (slug: string) => input.sets.filter((set) => set.section === slug);
|
||||
|
||||
const blocks = config.sections.map((section) => {
|
||||
const blocks = input.sections.map((section) => {
|
||||
const all = bySection(section.slug);
|
||||
const current = input.current.get(section.slug);
|
||||
const drafts = all.filter((set) => set.state === 'draft');
|
||||
@@ -147,7 +197,9 @@ export function renderDashboard(input: DashboardInput): Html {
|
||||
</form>
|
||||
</header>
|
||||
${input.notice === null ? '' : html`<p class="notice">${input.notice}</p>`}
|
||||
${input.error === null ? '' : html`<p class="notice notice--error">${input.error}</p>`}
|
||||
${blocks}
|
||||
${renderCategories(input)}
|
||||
</main>`,
|
||||
});
|
||||
}
|
||||
|
||||
+101
-29
@@ -24,6 +24,23 @@ export function mediaUrls(page: PageRecord): MediaUrls {
|
||||
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`;
|
||||
}
|
||||
@@ -47,22 +64,60 @@ export function setDateLabel(manifest: SetManifest): string {
|
||||
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>`;
|
||||
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 {
|
||||
@@ -125,10 +180,17 @@ function renderOlderMenus(section: SectionConfig, archives: readonly StoredSet[]
|
||||
</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 config order. */
|
||||
live: readonly { section: SectionConfig; set: StoredSet }[];
|
||||
/** Sections that have a Current Menu Set, in the order customers should see. */
|
||||
live: readonly MenuLink[];
|
||||
}
|
||||
|
||||
export function renderLanding(input: LandingInput): Html {
|
||||
@@ -136,12 +198,17 @@ export function renderLanding(input: LandingInput): Html {
|
||||
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">
|
||||
: html`<nav class="shelves" 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>
|
||||
(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>`,
|
||||
),
|
||||
)}
|
||||
@@ -171,8 +238,8 @@ export interface SectionInput {
|
||||
config: SiteConfig;
|
||||
section: SectionConfig;
|
||||
set: StoredSet;
|
||||
/** Section slugs that have a Current Menu Set, for the rail. */
|
||||
published: ReadonlySet<string>;
|
||||
/** 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. */
|
||||
@@ -184,11 +251,16 @@ export function renderSectionPage(input: SectionInput): Html {
|
||||
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>
|
||||
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.
|
||||
|
||||
+102
-12
@@ -15,13 +15,14 @@ import {
|
||||
verifyPassword,
|
||||
verifySessionToken,
|
||||
} from '../auth.ts';
|
||||
import { sectionBySlug, type SiteConfig } from '../config.ts';
|
||||
import type { SiteConfig } from '../config.ts';
|
||||
import { renderDashboard, renderEditor, renderLogin } from '../render/admin.ts';
|
||||
import { renderSectionPage } from '../render/pages.ts';
|
||||
import { menuLinks, renderSectionPage } from '../render/pages.ts';
|
||||
import { UnsupportedImageError } from '../images/pipeline.ts';
|
||||
import type { SetStore, StoredSet } from '../storage/sets.ts';
|
||||
import { StoreError } from '../storage/sets.ts';
|
||||
import { sessionCookieAttributes, SESSION_TTL_MS } from '../server.ts';
|
||||
import type { SectionStore } from '../storage/sections.ts';
|
||||
|
||||
const HTML = 'text/html; charset=utf-8';
|
||||
|
||||
@@ -34,6 +35,7 @@ export function describeBytes(bytes: number): string {
|
||||
|
||||
export interface AdminRouteDeps {
|
||||
config: SiteConfig;
|
||||
sections: SectionStore;
|
||||
store: SetStore;
|
||||
sessionSecret: string;
|
||||
adminPasswordHash: string | null;
|
||||
@@ -63,7 +65,7 @@ export function sameOrigin(request: FastifyRequest): boolean {
|
||||
}
|
||||
|
||||
export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRouteDeps): Promise<void> {
|
||||
const { config, store } = deps;
|
||||
const { config, sections, store } = deps;
|
||||
const throttle = deps.throttle ?? new LoginThrottle();
|
||||
|
||||
const sessionToken = (request: FastifyRequest): string | undefined =>
|
||||
@@ -82,13 +84,23 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
|
||||
const wantsJson = (request: FastifyRequest): boolean =>
|
||||
(request.headers['accept'] ?? '').includes('application/json');
|
||||
|
||||
const back = (request: FastifyRequest, reply: FastifyReply, notice: string | null) => {
|
||||
const backWith = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
message: { notice?: string | undefined; error?: string | undefined },
|
||||
) => {
|
||||
const target = request.headers['referer'];
|
||||
const base = typeof target === 'string' ? new URL(target, 'http://x').pathname : '/admin';
|
||||
const url = notice === null ? base : `${base}?notice=${encodeURIComponent(notice)}`;
|
||||
return reply.redirect(url, 303);
|
||||
const query = new URLSearchParams();
|
||||
if (message.notice !== undefined) query.set('notice', message.notice);
|
||||
if (message.error !== undefined) query.set('error', message.error);
|
||||
const suffix = query.toString();
|
||||
return reply.redirect(suffix === '' ? base : `${base}?${suffix}`, 303);
|
||||
};
|
||||
|
||||
const back = (request: FastifyRequest, reply: FastifyReply, notice: string | null) =>
|
||||
backWith(request, reply, notice === null ? {} : { notice });
|
||||
|
||||
const guard = async (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
@@ -169,15 +181,17 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
|
||||
|
||||
app.get('/admin/', async (request, reply) => {
|
||||
if (!authed(request)) return reply.redirect('/admin/login', 303);
|
||||
const notice = (request.query as { notice?: string }).notice ?? null;
|
||||
const query = request.query as { notice?: string; error?: string };
|
||||
return sendHtml(
|
||||
reply,
|
||||
renderDashboard({
|
||||
config,
|
||||
sections: sections.list(),
|
||||
sets: await store.list(),
|
||||
current: await store.currentBySection(),
|
||||
csrf: csrfToken(sessionToken(request), deps.sessionSecret),
|
||||
notice,
|
||||
notice: query.notice ?? null,
|
||||
error: query.error ?? null,
|
||||
}).value,
|
||||
);
|
||||
});
|
||||
@@ -190,7 +204,7 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
|
||||
return reply.code(403).type(HTML).send('That request was rejected. Reload the page and try again.');
|
||||
}
|
||||
const body = request.body as { section?: string; label?: string; validUntil?: string };
|
||||
const section = sectionBySlug(config, body.section ?? '');
|
||||
const section = sections.bySlug(body.section ?? '');
|
||||
if (section === undefined) return reply.code(400).send('Unknown Section.');
|
||||
try {
|
||||
const created = await store.create(section.slug, {
|
||||
@@ -211,7 +225,7 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
|
||||
} catch {
|
||||
return reply.code(404).type(HTML).send('That Menu Set no longer exists.');
|
||||
}
|
||||
const section = sectionBySlug(config, set.section);
|
||||
const section = sections.bySlug(set.section);
|
||||
if (section === undefined) return reply.code(404).send('Unknown Section.');
|
||||
const query = request.query as { notice?: string; error?: string };
|
||||
const current = (await store.currentBySection()).get(set.section);
|
||||
@@ -232,7 +246,7 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
|
||||
app.get<{ Params: { id: string } }>('/admin/sets/:id/preview', async (request, reply) => {
|
||||
if (!authed(request)) return reply.redirect('/admin/login', 303);
|
||||
const set = await store.get(request.params.id);
|
||||
const section = sectionBySlug(config, set.section);
|
||||
const section = sections.bySlug(set.section);
|
||||
if (section === undefined) return reply.code(404).send('Unknown Section.');
|
||||
const current = (await store.currentBySection()).get(section.slug);
|
||||
return sendHtml(
|
||||
@@ -241,7 +255,7 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
|
||||
config,
|
||||
section,
|
||||
set,
|
||||
published: new Set((await store.currentBySection()).keys()),
|
||||
menu: menuLinks(sections.list(), await store.currentBySection()),
|
||||
current: current?.id === set.id,
|
||||
preview: set.state === 'draft',
|
||||
archives: [],
|
||||
@@ -299,6 +313,82 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute
|
||||
return reply.redirect('/admin', 303);
|
||||
});
|
||||
|
||||
// ---------- Categories ----------
|
||||
|
||||
const guardAdmin = (request: FastifyRequest, reply: FastifyReply): boolean => {
|
||||
if (!authed(request)) {
|
||||
reply.redirect('/admin/login', 303);
|
||||
return false;
|
||||
}
|
||||
if (!sameOrigin(request) || !csrfOk(request)) {
|
||||
reply.code(403).type(HTML).send('That request was rejected. Reload the page and try again.');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const reason = (error: unknown): string =>
|
||||
error instanceof Error ? error.message : 'That did not work.';
|
||||
|
||||
app.post('/admin/sections', async (request, reply) => {
|
||||
if (!guardAdmin(request, reply)) return reply;
|
||||
const body = request.body as { label?: string };
|
||||
try {
|
||||
const created = await sections.create({ label: body.label ?? '' });
|
||||
return backWith(request, reply, {
|
||||
notice: `${created.label} added. Its web address is /${created.slug}/ — that stays put even if you rename it.`,
|
||||
});
|
||||
} catch (error) {
|
||||
return backWith(request, reply, { error: reason(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { slug: string } }>('/admin/sections/:slug/rename', async (request, reply) => {
|
||||
if (!guardAdmin(request, reply)) return reply;
|
||||
const body = request.body as { label?: string };
|
||||
try {
|
||||
const renamed = await sections.rename(request.params.slug, { label: body.label ?? '' });
|
||||
return backWith(request, reply, {
|
||||
notice: `${renamed.label} renamed. Its web address is still /${renamed.slug}/.`,
|
||||
});
|
||||
} catch (error) {
|
||||
return backWith(request, reply, { error: reason(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { slug: string } }>('/admin/sections/:slug/move', async (request, reply) => {
|
||||
if (!guardAdmin(request, reply)) return reply;
|
||||
const body = request.body as { direction?: string };
|
||||
try {
|
||||
await sections.move(request.params.slug, body.direction === 'up' ? 'up' : 'down');
|
||||
} catch (error) {
|
||||
return backWith(request, reply, { error: reason(error) });
|
||||
}
|
||||
return backWith(request, reply, {});
|
||||
});
|
||||
|
||||
app.post<{ Params: { slug: string } }>('/admin/sections/:slug/delete', async (request, reply) => {
|
||||
if (!guardAdmin(request, reply)) return reply;
|
||||
const existing = sections.bySlug(request.params.slug);
|
||||
if (existing === undefined) {
|
||||
return backWith(request, reply, { error: `No such category: ${request.params.slug}` });
|
||||
}
|
||||
const affected = (await store.list()).filter((set) => set.section === existing.slug);
|
||||
if (affected.length > 0) {
|
||||
return backWith(request, reply, {
|
||||
error:
|
||||
`${existing.label} still holds ${affected.length} menu ` +
|
||||
`${affected.length === 1 ? 'set' : 'sets'}. Delete those first, or rename the category instead.`,
|
||||
});
|
||||
}
|
||||
try {
|
||||
await sections.remove(existing.slug);
|
||||
} catch (error) {
|
||||
return backWith(request, reply, { error: reason(error) });
|
||||
}
|
||||
return backWith(request, reply, { notice: `${existing.label} removed.` });
|
||||
});
|
||||
|
||||
// ---------- Pages ----------
|
||||
|
||||
app.post<{ Params: { id: string } }>('/admin/sets/:id/pages', async (request, reply) => {
|
||||
|
||||
+18
-16
@@ -2,17 +2,24 @@ import { createReadStream } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { sectionBySlug, type SiteConfig } from '../config.ts';
|
||||
import type { SiteConfig } from '../config.ts';
|
||||
import type { SectionStore } from '../storage/sections.ts';
|
||||
import { SESSION_COOKIE, verifySessionToken } from '../auth.ts';
|
||||
import { variantFileNames } from '../storage/manifest.ts';
|
||||
import type { SetStore } from '../storage/sets.ts';
|
||||
import { renderLanding, renderNotFound, renderSectionPage } from '../render/pages.ts';
|
||||
import {
|
||||
menuLinks,
|
||||
renderLanding,
|
||||
renderNotFound,
|
||||
renderSectionPage,
|
||||
} from '../render/pages.ts';
|
||||
|
||||
const HTML = 'text/html; charset=utf-8';
|
||||
const MEDIA_NAME = /^(thumb-)?([0-9a-f-]{36})\.([0-9a-f]{8,64})\.(webp|jpg)$/;
|
||||
|
||||
export interface PublicRouteDeps {
|
||||
config: SiteConfig;
|
||||
sections: SectionStore;
|
||||
store: SetStore;
|
||||
sessionSecret: string;
|
||||
}
|
||||
@@ -43,13 +50,12 @@ export async function registerPublicRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: PublicRouteDeps,
|
||||
): Promise<void> {
|
||||
const { config, store } = deps;
|
||||
const { config, sections, store } = deps;
|
||||
|
||||
const isAuthed = (request: FastifyRequest): boolean =>
|
||||
verifySessionToken(request.cookies[SESSION_COOKIE], deps.sessionSecret);
|
||||
|
||||
const publishedSections = async (): Promise<Set<string>> =>
|
||||
new Set((await store.currentBySection()).keys());
|
||||
const menu = async () => menuLinks(sections.list(), await store.currentBySection());
|
||||
|
||||
app.get('/healthz', async (_request, reply) => reply.type('text/plain').send('ok'));
|
||||
|
||||
@@ -58,21 +64,17 @@ export async function registerPublicRoutes(
|
||||
);
|
||||
|
||||
app.get('/', async (_request, reply) => {
|
||||
const current = await store.currentBySection();
|
||||
const live = config.sections
|
||||
.filter((section) => current.has(section.slug))
|
||||
.map((section) => ({ section, set: current.get(section.slug)! }));
|
||||
return sendHtml(reply, renderLanding({ config, live }).value);
|
||||
return sendHtml(reply, renderLanding({ config, live: await menu() }).value);
|
||||
});
|
||||
|
||||
app.get<{ Params: { section: string } }>('/:section/', async (request, reply) => {
|
||||
const section = sectionBySlug(config, request.params.section);
|
||||
const section = sections.bySlug(request.params.section);
|
||||
if (section === undefined) {
|
||||
return sendHtml(reply, renderNotFound(config, []).value, 404);
|
||||
}
|
||||
const current = (await store.currentBySection()).get(section.slug);
|
||||
if (current === undefined) {
|
||||
return sendHtml(reply, renderNotFound(config, config.sections).value, 404);
|
||||
return sendHtml(reply, renderNotFound(config, sections.list()).value, 404);
|
||||
}
|
||||
return sendHtml(
|
||||
reply,
|
||||
@@ -80,7 +82,7 @@ export async function registerPublicRoutes(
|
||||
config,
|
||||
section,
|
||||
set: current,
|
||||
published: await publishedSections(),
|
||||
menu: await menu(),
|
||||
current: true,
|
||||
preview: false,
|
||||
archives: await store.archives(section.slug),
|
||||
@@ -91,7 +93,7 @@ export async function registerPublicRoutes(
|
||||
app.get<{ Params: { section: string; slug: string } }>(
|
||||
'/:section/:slug/',
|
||||
async (request, reply) => {
|
||||
const section = sectionBySlug(config, request.params.section);
|
||||
const section = sections.bySlug(request.params.section);
|
||||
if (section === undefined) {
|
||||
return sendHtml(reply, renderNotFound(config, []).value, 404);
|
||||
}
|
||||
@@ -102,7 +104,7 @@ export async function registerPublicRoutes(
|
||||
candidate.slug === request.params.slug,
|
||||
);
|
||||
if (set === undefined) {
|
||||
return sendHtml(reply, renderNotFound(config, config.sections).value, 404);
|
||||
return sendHtml(reply, renderNotFound(config, sections.list()).value, 404);
|
||||
}
|
||||
const current = (await store.currentBySection()).get(section.slug);
|
||||
const archives = (await store.archives(section.slug)).filter(
|
||||
@@ -114,7 +116,7 @@ export async function registerPublicRoutes(
|
||||
config,
|
||||
section,
|
||||
set,
|
||||
published: await publishedSections(),
|
||||
menu: await menu(),
|
||||
current: current?.id === set.id,
|
||||
preview: false,
|
||||
archives,
|
||||
|
||||
+19
-6
@@ -15,6 +15,7 @@ import { parseSiteConfig, type SiteConfig } from './config.ts';
|
||||
import { SESSION_COOKIE, sessionTtl, type LoginThrottle } from './auth.ts';
|
||||
import { createImagePipeline } from './images/pipeline.ts';
|
||||
import { SetStore, type ImagePipeline } from './storage/sets.ts';
|
||||
import { SectionStore } from './storage/sections.ts';
|
||||
import { hasFatal, validateSite } from './storage/validate.ts';
|
||||
import { renderNotFound } from './render/pages.ts';
|
||||
import { registerPublicRoutes } from './routes/public.ts';
|
||||
@@ -26,6 +27,7 @@ export const SESSION_TTL_MS = 8 * 60 * 60 * 1000;
|
||||
/** What every route reaches for: the store, the session secret, the admin hash. */
|
||||
export interface MenuContext {
|
||||
store: SetStore;
|
||||
sections: SectionStore;
|
||||
sessionSecret: string;
|
||||
sessionTtlMs: number;
|
||||
adminPasswordHash: string | null;
|
||||
@@ -53,6 +55,8 @@ export interface AppOptions {
|
||||
now?: () => Date;
|
||||
/** Test seam: a shared throttle so rate-limit tests are deterministic. */
|
||||
throttle?: LoginThrottle;
|
||||
/** Created and loaded by main so boot validation sees the same list. */
|
||||
sectionsStore?: SectionStore;
|
||||
}
|
||||
|
||||
export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
|
||||
@@ -80,22 +84,29 @@ export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
|
||||
maxAge: '5m',
|
||||
});
|
||||
|
||||
const sections =
|
||||
options.sectionsStore ??
|
||||
new SectionStore({ dataDir: options.dataDir, seed: options.config.sections });
|
||||
await sections.load();
|
||||
|
||||
const store = new SetStore({
|
||||
dataDir: options.dataDir,
|
||||
pipeline: options.pipeline ?? createImagePipeline(),
|
||||
sections: options.config.sections.map((section) => section.slug),
|
||||
sections,
|
||||
maxPagesPerSet: options.config.limits.maxPagesPerSet,
|
||||
now: options.now,
|
||||
});
|
||||
|
||||
await registerPublicRoutes(app, {
|
||||
config: options.config,
|
||||
sections,
|
||||
store,
|
||||
sessionSecret: options.sessionSecret,
|
||||
});
|
||||
|
||||
await registerAdminRoutes(app, {
|
||||
config: options.config,
|
||||
sections,
|
||||
store,
|
||||
sessionSecret: options.sessionSecret,
|
||||
adminPasswordHash: options.adminPasswordHash,
|
||||
@@ -106,7 +117,7 @@ export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
|
||||
|
||||
app.setNotFoundHandler(async (_request, reply) => {
|
||||
const current = await store.currentBySection();
|
||||
const live = options.config.sections.filter((section) => current.has(section.slug));
|
||||
const live = sections.list().filter((section) => current.has(section.slug));
|
||||
return reply
|
||||
.code(404)
|
||||
.type('text/html; charset=utf-8')
|
||||
@@ -121,6 +132,7 @@ export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
|
||||
|
||||
app.decorate('menu', {
|
||||
store,
|
||||
sections,
|
||||
sessionSecret: options.sessionSecret,
|
||||
sessionTtlMs: SESSION_TTL_MS,
|
||||
adminPasswordHash: options.adminPasswordHash,
|
||||
@@ -162,10 +174,10 @@ async function main(): Promise<void> {
|
||||
console.warn('ADMIN_PASSWORD_HASH is not set; the admin is disabled');
|
||||
}
|
||||
|
||||
const problems = await validateSite({
|
||||
dataDir,
|
||||
sections: config.sections.map((section) => section.slug),
|
||||
});
|
||||
const sections = new SectionStore({ dataDir, seed: config.sections });
|
||||
await sections.load();
|
||||
|
||||
const problems = await validateSite({ dataDir, sections });
|
||||
for (const problem of problems) {
|
||||
const line = `${problem.level}: ${problem.message}`;
|
||||
if (problem.level === 'fatal') console.error(line);
|
||||
@@ -179,6 +191,7 @@ async function main(): Promise<void> {
|
||||
|
||||
const app = await buildApp({
|
||||
config,
|
||||
sectionsStore: sections,
|
||||
dataDir,
|
||||
sessionSecret,
|
||||
adminPasswordHash,
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Sections are Site *content*, not Site configuration: they can be added,
|
||||
* renamed, reordered and removed from the admin. The list lives in the data
|
||||
* directory, so it is covered by the same backup as the menus, and the config
|
||||
* file's Section list is only the seed for a Site that has never been opened.
|
||||
*
|
||||
* A Section's URL is fixed once created: labels change, slugs do not, because
|
||||
* the URL is what gets printed on a table card or a QR code.
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { RESERVED_SLUGS, type SectionConfig } from '../config.ts';
|
||||
import { writeJsonAtomic } from './manifest.ts';
|
||||
|
||||
export class SectionError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(message: string, status = 400) {
|
||||
super(message);
|
||||
this.name = 'SectionError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/** "What's On" -> "whats-on", "Straight Drinks" -> "straight-drinks" */
|
||||
export function slugify(label: string): string {
|
||||
return label
|
||||
.normalize('NFKD')
|
||||
.replace(/['’`]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function cleanLabel(label: unknown, where = 'label'): string {
|
||||
if (typeof label !== 'string' || label.trim() === '') {
|
||||
throw new SectionError(`${where} cannot be empty`);
|
||||
}
|
||||
return label.trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function parse(raw: unknown): SectionConfig[] {
|
||||
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
||||
throw new SectionError('sections.json must be an object');
|
||||
}
|
||||
const sections = (raw as { sections?: unknown }).sections;
|
||||
if (!Array.isArray(sections)) throw new SectionError('sections.json must hold a sections array');
|
||||
|
||||
const seen = new Set<string>();
|
||||
const parsed = sections.map((entry, index) => {
|
||||
if (typeof entry !== 'object' || entry === null) {
|
||||
throw new SectionError(`sections[${index}] must be an object`);
|
||||
}
|
||||
const candidate = entry as Record<string, unknown>;
|
||||
const slug = candidate['slug'];
|
||||
if (typeof slug !== 'string' || slug === '') {
|
||||
throw new SectionError(`sections[${index}].slug must be a string`);
|
||||
}
|
||||
if (seen.has(slug)) throw new SectionError(`two Sections share the slug ${slug}`);
|
||||
seen.add(slug);
|
||||
const order = candidate['order'];
|
||||
if (typeof order !== 'number' || !Number.isSafeInteger(order)) {
|
||||
throw new SectionError(`sections[${index}].order must be a whole number`);
|
||||
}
|
||||
const label = cleanLabel(candidate['label'], `sections[${index}].label`);
|
||||
return {
|
||||
slug,
|
||||
label,
|
||||
shortLabel: typeof candidate['shortLabel'] === 'string' && candidate['shortLabel'] !== ''
|
||||
? candidate['shortLabel']
|
||||
: label,
|
||||
note: typeof candidate['note'] === 'string' && candidate['note'] !== '' ? candidate['note'] : null,
|
||||
order,
|
||||
} satisfies SectionConfig;
|
||||
});
|
||||
|
||||
return parsed.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
export interface SectionStoreOptions {
|
||||
dataDir: string;
|
||||
/** Used only when the Site has no sections.json yet. */
|
||||
seed: readonly SectionConfig[];
|
||||
}
|
||||
|
||||
export class SectionStore {
|
||||
readonly file: string;
|
||||
|
||||
private readonly seed: readonly SectionConfig[];
|
||||
private sections: SectionConfig[] | null = null;
|
||||
|
||||
constructor(options: SectionStoreOptions) {
|
||||
this.file = path.join(options.dataDir, 'sections.json');
|
||||
this.seed = options.seed;
|
||||
}
|
||||
|
||||
/** Reads the list once; every later access is an in-memory read. */
|
||||
async load(): Promise<void> {
|
||||
if (this.sections !== null) return;
|
||||
try {
|
||||
this.sections = parse(JSON.parse(await readFile(this.file, 'utf8')));
|
||||
return;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw new SectionError(
|
||||
`${this.file} could not be read: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.sections = [...this.seed].sort((a, b) => a.order - b.order);
|
||||
if (this.sections.length > 0) await this.save(this.sections);
|
||||
}
|
||||
|
||||
list(): readonly SectionConfig[] {
|
||||
if (this.sections === null) throw new Error('SectionStore.load() has not been awaited');
|
||||
return this.sections;
|
||||
}
|
||||
|
||||
bySlug(slug: string): SectionConfig | undefined {
|
||||
return this.list().find((section) => section.slug === slug);
|
||||
}
|
||||
|
||||
has(slug: string): boolean {
|
||||
return this.bySlug(slug) !== undefined;
|
||||
}
|
||||
|
||||
async create(input: { label: string }): Promise<SectionConfig> {
|
||||
const label = cleanLabel(input.label);
|
||||
const slug = this.uniqueSlug(slugify(label));
|
||||
if (slug === '') throw new SectionError(`"${label}" does not make a usable web address`);
|
||||
const order = this.list().reduce((max, section) => Math.max(max, section.order), 0) + 1;
|
||||
const created: SectionConfig = { slug, label, shortLabel: label, note: null, order };
|
||||
await this.save([...this.list(), created]);
|
||||
return created;
|
||||
}
|
||||
|
||||
async rename(slug: string, input: { label: string }): Promise<SectionConfig> {
|
||||
const label = cleanLabel(input.label);
|
||||
const existing = this.require(slug);
|
||||
const renamed: SectionConfig = { ...existing, label, shortLabel: label };
|
||||
await this.save(this.list().map((section) => (section.slug === slug ? renamed : section)));
|
||||
return renamed;
|
||||
}
|
||||
|
||||
async move(slug: string, direction: 'up' | 'down'): Promise<void> {
|
||||
this.require(slug);
|
||||
const ordered = [...this.list()];
|
||||
const index = ordered.findIndex((section) => section.slug === slug);
|
||||
const target = direction === 'up' ? index - 1 : index + 1;
|
||||
if (target < 0 || target >= ordered.length) return;
|
||||
const [moved] = ordered.splice(index, 1);
|
||||
ordered.splice(target, 0, moved!);
|
||||
await this.save(ordered.map((section, position) => ({ ...section, order: position + 1 })));
|
||||
}
|
||||
|
||||
async remove(slug: string): Promise<void> {
|
||||
this.require(slug);
|
||||
await this.save(
|
||||
this.list()
|
||||
.filter((section) => section.slug !== slug)
|
||||
.map((section, position) => ({ ...section, order: position + 1 })),
|
||||
);
|
||||
}
|
||||
|
||||
private require(slug: string): SectionConfig {
|
||||
const found = this.bySlug(slug);
|
||||
if (found === undefined) throw new SectionError(`no such Section: ${slug}`, 404);
|
||||
return found;
|
||||
}
|
||||
|
||||
private uniqueSlug(base: string): string {
|
||||
if (!RESERVED_SLUGS.has(base)) {
|
||||
const taken = this.list().some((section) => section.slug === base);
|
||||
if (!taken) return base;
|
||||
}
|
||||
for (let suffix = 2; suffix < 100; suffix += 1) {
|
||||
const candidate = `${base}-${suffix}`;
|
||||
if (!RESERVED_SLUGS.has(candidate) && !this.list().some((s) => s.slug === candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
throw new SectionError(`could not find a free web address for "${base}"`);
|
||||
}
|
||||
|
||||
private async save(sections: readonly SectionConfig[]): Promise<void> {
|
||||
const sorted = [...sections].sort((a, b) => a.order - b.order);
|
||||
await writeJsonAtomic(this.file, { sections: sorted });
|
||||
this.sections = sorted;
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -88,8 +88,8 @@ export class StoreError extends Error {
|
||||
export interface SetStoreOptions {
|
||||
dataDir: string;
|
||||
pipeline: ImagePipeline;
|
||||
/** Configured Section slugs; a set may only belong to one of these. */
|
||||
sections: readonly string[];
|
||||
/** The Sections that exist; a Menu Set may only belong to one of these. */
|
||||
sections: { has(slug: string): boolean };
|
||||
maxPagesPerSet: number;
|
||||
timeZone?: string | undefined;
|
||||
now?: (() => Date) | undefined;
|
||||
@@ -116,7 +116,7 @@ export class SetStore {
|
||||
readonly paths: SitePaths;
|
||||
|
||||
private readonly pipeline: ImagePipeline;
|
||||
private readonly sections: readonly string[];
|
||||
private readonly sections: { has(slug: string): boolean };
|
||||
private readonly maxPagesPerSet: number;
|
||||
private readonly timeZone: string;
|
||||
private readonly now: () => Date;
|
||||
@@ -200,7 +200,7 @@ export class SetStore {
|
||||
section: string,
|
||||
input: { label: string; validUntil?: string | null },
|
||||
): Promise<StoredSet> {
|
||||
if (!this.sections.includes(section)) {
|
||||
if (!this.sections.has(section)) {
|
||||
throw new StoreError(`unknown Section: ${section}`);
|
||||
}
|
||||
const label = input.label.trim();
|
||||
|
||||
@@ -15,7 +15,8 @@ export interface Problem {
|
||||
|
||||
export interface ValidateOptions {
|
||||
dataDir: string;
|
||||
sections: readonly string[];
|
||||
/** Answers whether a slug still exists, so a removed Section is a problem. */
|
||||
sections: { has(slug: string): boolean };
|
||||
}
|
||||
|
||||
async function exists(target: string): Promise<boolean> {
|
||||
@@ -75,7 +76,7 @@ export async function validateSite(options: ValidateOptions): Promise<Problem[]>
|
||||
|
||||
for (const set of sets) {
|
||||
const where = `${set.state}/${set.manifest.section}/${set.slug ?? set.manifest.id}`;
|
||||
if (!options.sections.includes(set.manifest.section)) {
|
||||
if (!options.sections.has(set.manifest.section)) {
|
||||
problems.push({ level: 'fatal', message: `${where}: Section is not in the Site config` });
|
||||
}
|
||||
if (set.manifest.section !== path.basename(path.dirname(set.dir))) {
|
||||
|
||||
Reference in new issue
Block a user