feat(cms): install Payload and serve the admin panel
Payload 3.88 runs inside the Next app against the existing Postgres, in
its own 'payload' schema so no pipeline operation on public — the app
tables, Airflow's metadata, migrate_csv_to_db.py --drop — can reach blog
content.
Its REST API is mounted at /cms-api. /api is the FastAPI proxy's
catch-all, which would swallow every admin call and forward it to the
backend with no error. The mount points live in lib/payloadRoutes.ts so
there is one definition and a test can assert it without importing
Payload: it is ESM-only, next/jest will not transform it, and appending
transformIgnorePatterns cannot un-ignore a package. Forcing it through
transpilePackages would change how the production build bundles Payload
to serve a test, so the live proof that /api still reaches FastAPI stays
where it belongs — the e2e journeys, which call /api/schools.
The package becomes ESM ("type": "module"), which Payload's CLI requires:
richtext-lexical has top-level await and the config cannot be require()d.
Only two files needed renaming, jest.config.cjs and a build script.
The build is verified to succeed with DATABASE_URL and PAYLOAD_SECRET
both unset, which is how CI builds it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM
This commit is contained in:
1 parent
2437ffce42
commit
c5a4d106da
19 files changed
+5441
-226
No files matched your search
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Derivatives for the landing hero artwork.
|
||||
*
|
||||
* node scripts/build-hero-images.js
|
||||
*
|
||||
* Source of truth is assets/hero-source.png (1672×941). It is committed so
|
||||
* these can be regenerated: never hand-edit anything in public/brand/hero-*,
|
||||
* change the source or the numbers here and re-run.
|
||||
*
|
||||
* TWO CROPS, ON PURPOSE
|
||||
* ---------------------
|
||||
* The hero slot is not one shape. On desktop the artwork sits behind the whole
|
||||
* panel at roughly 2.1:1 → 2.7:1; below the one-column breakpoint it collapses
|
||||
* to a band between 2.6:1 and 4.9:1. A single file under `object-fit: cover`
|
||||
* centre-crops, which at the extremes slices a strip through the middle of the
|
||||
* scene and loses the schoolhouse — the same failure the previous SVG hero had.
|
||||
*
|
||||
* So:
|
||||
* wide the full frame, including the empty cream area the artwork reserves
|
||||
* on the left for the headline. Used behind the desktop panel.
|
||||
* band a crop around the schoolhouse and the path, already near 2.6:1, so
|
||||
* the narrow band barely crops it at all. The school sits at ~65%
|
||||
* across, which is why the CSS pairs this with object-position: 65%:
|
||||
* squeezing further toward 4.9:1 crops the empty sides, not the subject.
|
||||
*
|
||||
* AVIF and WebP are both emitted. This is flat-shaded vector-style artwork, so
|
||||
* AVIF lands roughly an order of magnitude under the source PNG. Both crops
|
||||
* also get a JPEG: the wide one backs the <img src>, and the band one backs a
|
||||
* <source media> — without it a browser taking neither modern format would
|
||||
* fall through to the desktop frame on a phone, since <img src> cannot vary by
|
||||
* viewport.
|
||||
*
|
||||
* sharp is declared in devDependencies rather than leaned on as a transitive
|
||||
* dependency of next. It does currently arrive that way, but relying on that
|
||||
* means a Next upgrade can silently break this script — the same reasoning as
|
||||
* the outputFileTracingIncludes note in next.config.js.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const sharp = require('sharp');
|
||||
|
||||
const SRC = path.join(__dirname, '..', 'assets', 'hero-source.png');
|
||||
const OUT = path.join(__dirname, '..', 'public', 'brand');
|
||||
|
||||
/*
|
||||
* Band crop, in source pixels, measured against the artwork rather than
|
||||
* guessed: the schoolhouse spans roughly x 1420–1860, y 420–660, with its flag
|
||||
* up at y 330.
|
||||
*
|
||||
* The crop is flush to the right edge because it has to be. The school sits at
|
||||
* about 82% across the frame, so there is only ~140px of artwork to its right
|
||||
* — not enough to centre it. Anchoring right and taking 1050px back puts the
|
||||
* school at ~66% across the crop, which is what BAND_FOCUS_X below reports to
|
||||
* the CSS so the narrowest band crops the empty left side rather than the
|
||||
* subject.
|
||||
*/
|
||||
const BAND = { left: 656, top: 300, width: 1344, height: 420 };
|
||||
|
||||
/** Where the schoolhouse lands in the band crop, for CSS object-position. */
|
||||
const BAND_FOCUS_X = Math.round(((1420 + 1860) / 2 - BAND.left) / BAND.width * 100);
|
||||
|
||||
const WIDE_WIDTHS = [2000, 1400, 1000];
|
||||
const BAND_WIDTHS = [1344, 900, 600];
|
||||
|
||||
async function emit(pipeline, name, width) {
|
||||
const base = pipeline.clone().resize({ width, withoutEnlargement: true });
|
||||
await base.clone().avif({ quality: 62, effort: 6 }).toFile(path.join(OUT, `${name}-${width}.avif`));
|
||||
await base.clone().webp({ quality: 80, effort: 6 }).toFile(path.join(OUT, `${name}-${width}.webp`));
|
||||
return width;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!fs.existsSync(SRC)) throw new Error(`missing source: ${SRC}`);
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const meta = await sharp(SRC).metadata();
|
||||
console.log(`source ${meta.width}×${meta.height}`);
|
||||
console.log(`band focus x: ${BAND_FOCUS_X}% (keep .heroArt object-position in step)`);
|
||||
|
||||
const wide = sharp(SRC);
|
||||
for (const w of WIDE_WIDTHS) await emit(wide, 'hero-wide', w);
|
||||
// Fallback for the <img> itself. Flattened onto the artwork's own cream so a
|
||||
// JPEG (no alpha) can never composite against black.
|
||||
await sharp(SRC)
|
||||
.resize({ width: 1400 })
|
||||
.flatten({ background: '#FEF2E1' })
|
||||
.jpeg({ quality: 82, mozjpeg: true })
|
||||
.toFile(path.join(OUT, 'hero-wide-1400.jpg'));
|
||||
|
||||
const band = sharp(SRC).extract(BAND);
|
||||
for (const w of BAND_WIDTHS) await emit(band, 'hero-band', w);
|
||||
await sharp(SRC)
|
||||
.extract(BAND)
|
||||
.resize({ width: 900 })
|
||||
.flatten({ background: '#FEF2E1' })
|
||||
.jpeg({ quality: 82, mozjpeg: true })
|
||||
.toFile(path.join(OUT, 'hero-band-900.jpg'));
|
||||
|
||||
const files = fs.readdirSync(OUT).filter(f => f.startsWith('hero-')).sort();
|
||||
let total = 0;
|
||||
for (const f of files) {
|
||||
const { size } = fs.statSync(path.join(OUT, f));
|
||||
total += size;
|
||||
console.log(` ${f.padEnd(24)} ${(size / 1024).toFixed(1)} kB`);
|
||||
}
|
||||
console.log(`total ${(total / 1024).toFixed(1)} kB across ${files.length} files`);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in new issue
Block a user