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.
67 lines
1.8 KiB
TypeScript
67 lines
1.8 KiB
TypeScript
import { mkdir, mkdtemp } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { createHash } from 'node:crypto';
|
|
import { SetStore, type ImagePipeline } from '../../src/storage/sets.ts';
|
|
|
|
export const SECTIONS = ['bar', 'food'] as const;
|
|
|
|
/** Deterministic stand-in for sharp, so storage tests need no image work. */
|
|
export class FakePipeline implements ImagePipeline {
|
|
calls = 0;
|
|
|
|
async process(data: Buffer, _originalName: string) {
|
|
this.calls += 1;
|
|
const hash = createHash('sha256').update(data).digest('hex').slice(0, 8);
|
|
return {
|
|
sourceExt: 'jpg' as const,
|
|
hash,
|
|
width: 3000,
|
|
height: 2000,
|
|
original: data,
|
|
webp: Buffer.from(`webp:${hash}`),
|
|
jpeg: Buffer.from(`jpeg:${hash}`),
|
|
thumb: Buffer.from(`thumb:${hash}`),
|
|
};
|
|
}
|
|
}
|
|
|
|
export function testClock(start = '2026-01-01T00:00:00.000Z') {
|
|
let time = Date.parse(start);
|
|
return {
|
|
now: () => new Date(time),
|
|
advance: (ms: number) => {
|
|
time += ms;
|
|
},
|
|
set: (iso: string) => {
|
|
time = Date.parse(iso);
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function tempDir(prefix: string): Promise<string> {
|
|
const base = path.join(process.cwd(), '.tmp');
|
|
await mkdir(base, { recursive: true });
|
|
return mkdtemp(path.join(base, `${prefix}-`));
|
|
}
|
|
|
|
export function makeStore(
|
|
dataDir: string,
|
|
options: { clock?: ReturnType<typeof testClock>; maxPagesPerSet?: number } = {},
|
|
) {
|
|
const pipeline = new FakePipeline();
|
|
const clock = options.clock ?? testClock();
|
|
const store = new SetStore({
|
|
dataDir,
|
|
pipeline,
|
|
sections: { has: (slug: string) => (SECTIONS as readonly string[]).includes(slug) },
|
|
maxPagesPerSet: options.maxPagesPerSet ?? 40,
|
|
timeZone: 'Asia/Kolkata',
|
|
now: clock.now,
|
|
});
|
|
return { store, pipeline, clock };
|
|
}
|
|
|
|
export function pngBytes(label: string): Buffer {
|
|
return Buffer.from(`fake-image:${label}`);
|
|
}
|