Ships `dev` as **v0.11.0**. `main` contains nothing that `dev` does not, so this is a clean sync. ## Headline changes since v0.10.0 - **Journal & Journal Studio** — photo essays at `/journal`, split-screen block editor with live preview, drafts, per-entry passwords, cover images - **First-run setup wizard at `/install`** — configure a fresh deployment from the browser; credentials are verified against Immich before anything is written, and the wizard is gated by a one-time token - **About page editor and toggle** in the admin panel - **Per-album photo order** independent of Immich, with a drag & drop editor - **Custom favicon upload** - **Experimental portfolio features** — justified grid, `cover` hero, unlisted subpages, per-album grid overrides, cover focal points, external nav links - **Security** — asset tokens length-capped before decoding (#423), journal author markdown escaped rather than filtered, journal file paths contained, admin session key derived with scrypt (#432), Next.js 16.3.0 for CVE-2025-13465 - **Fixed** — client proofing honours its own configuration again (#454); journal photo rendering (#432) Full detail in [CHANGELOG.md](CHANGELOG.md#0110--2026-08-15). ## Verified `npx tsc --noEmit` clean · `npx vitest run` 435 tests pass · `npm run build` succeeds · lint 0 errors · `prettier --check` clean · local `docker build` of the release tree. ## After merge Tag `v0.11.0` on `main` and publish the release — that triggers `docker-publish.yml`, which pushes the image and moves `latest`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
169 lines
5.3 KiB
TypeScript
169 lines
5.3 KiB
TypeScript
/**
|
|
* README Header Collage Generator
|
|
*
|
|
* Composes the single hero image at the top of README.md out of screenshots
|
|
* that already exist in docs/screenshots/. One picture that answers "what is
|
|
* this?" in the three ways a visitor cares about: the public gallery, the
|
|
* photo grid, and the admin panel behind it.
|
|
*
|
|
* Usage:
|
|
* npx tsx scripts/header.ts
|
|
*
|
|
* Run scripts/screenshots.ts first if the source shots are missing or stale —
|
|
* this script only composes, it never captures.
|
|
*/
|
|
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
const SCREENSHOT_DIR = path.join(process.cwd(), 'docs', 'screenshots');
|
|
const OUTPUT = path.join(SCREENSHOT_DIR, 'header.png');
|
|
|
|
/** Canvas and spacing. Sized so the result stays legible at README width. */
|
|
const CANVAS = { width: 2400, height: 1000 };
|
|
const PADDING = 28;
|
|
const GAP = 20;
|
|
|
|
/** Near-black, matching the studio-modern preset the shots are taken in. */
|
|
const BACKGROUND = { r: 13, g: 13, b: 13, alpha: 1 };
|
|
/** Hairline around each tile, so neighbouring dark screenshots stay distinct. */
|
|
const BORDER = { r: 42, g: 42, b: 42, alpha: 1 };
|
|
|
|
interface Tile {
|
|
file: string;
|
|
left: number;
|
|
top: number;
|
|
width: number;
|
|
height: number;
|
|
/** Which part survives the crop — screenshots read best from the top. */
|
|
position: 'top' | 'centre';
|
|
/**
|
|
* Optional region of the source to use instead of the whole page. A dense
|
|
* UI shrunk to tile size turns into unreadable mush; taking a smaller region
|
|
* and scaling it less keeps the interface recognisable.
|
|
*/
|
|
crop?: { left: number; top: number; width: number; height: number };
|
|
}
|
|
|
|
function layout(): Tile[] {
|
|
const innerHeight = CANVAS.height - PADDING * 2;
|
|
// One large tile carrying the gallery, two stacked beside it. The large one
|
|
// takes roughly the golden share of the width; the rest goes to the column.
|
|
const largeWidth = 1480;
|
|
const columnLeft = PADDING + largeWidth + GAP;
|
|
const columnWidth = CANVAS.width - columnLeft - PADDING;
|
|
const columnTileHeight = Math.floor((innerHeight - GAP) / 2);
|
|
|
|
return [
|
|
{
|
|
file: 'theme-studio-modern-home.png',
|
|
left: PADDING,
|
|
top: PADDING,
|
|
width: largeWidth,
|
|
height: innerHeight,
|
|
position: 'top',
|
|
},
|
|
{
|
|
file: 'theme-studio-modern-grid.png',
|
|
left: columnLeft,
|
|
top: PADDING,
|
|
width: columnWidth,
|
|
height: columnTileHeight,
|
|
position: 'top',
|
|
},
|
|
{
|
|
file: 'admin-page-builder.png',
|
|
left: columnLeft,
|
|
top: PADDING + columnTileHeight + GAP,
|
|
width: columnWidth,
|
|
height: columnTileHeight,
|
|
position: 'top',
|
|
// The page builder is the densest shot in the set. Full width, but only
|
|
// the upper region: tab bar, save bar and the hero thumbnails. Scaling
|
|
// ~1000px of source instead of 1440 keeps the labels readable.
|
|
crop: { left: 0, top: 0, width: 1030, height: 564 },
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* sharp ships with Next.js but is not a declared dependency, and 0.35 moved
|
|
* from `export =` to ESM — the namespace stopped being callable and the
|
|
* factory moved to `.default`. Derive the shape from the installed package
|
|
* rather than pinning it, exactly as scripts/screenshots.ts does.
|
|
*/
|
|
async function loadSharp() {
|
|
type SharpFactory = typeof import('sharp') extends { default: infer Factory }
|
|
? Factory
|
|
: typeof import('sharp');
|
|
const mod = (await import('sharp')) as unknown as { default?: SharpFactory };
|
|
return mod.default ?? (mod as unknown as SharpFactory);
|
|
}
|
|
|
|
async function main() {
|
|
const tiles = layout();
|
|
|
|
const missing = tiles
|
|
.map((t) => t.file)
|
|
.filter((f) => !fs.existsSync(path.join(SCREENSHOT_DIR, f)));
|
|
if (missing.length > 0) {
|
|
console.error(
|
|
`\n❌ Missing source screenshots:\n ${missing.join('\n ')}\n\n` +
|
|
` Run \`npx tsx scripts/screenshots.ts\` against a running site first.\n`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
let sharp: Awaited<ReturnType<typeof loadSharp>>;
|
|
try {
|
|
sharp = await loadSharp();
|
|
} catch {
|
|
console.error('\n❌ sharp is not available — cannot compose the header.\n');
|
|
process.exit(1);
|
|
}
|
|
|
|
const composites = await Promise.all(
|
|
tiles.map(async (tile) => {
|
|
// Reserve a pixel on every side for the hairline, so the visible
|
|
// screenshot lands exactly on the tile geometry above.
|
|
const pipeline = sharp(path.join(SCREENSHOT_DIR, tile.file));
|
|
if (tile.crop) pipeline.extract(tile.crop);
|
|
|
|
const inner = await pipeline
|
|
.resize(tile.width - 2, tile.height - 2, {
|
|
fit: 'cover',
|
|
position: tile.position === 'top' ? 'top' : 'centre',
|
|
})
|
|
.toBuffer();
|
|
|
|
const bordered = await sharp(inner)
|
|
.extend({ top: 1, bottom: 1, left: 1, right: 1, background: BORDER })
|
|
.toBuffer();
|
|
|
|
return { input: bordered, left: tile.left, top: tile.top };
|
|
}),
|
|
);
|
|
|
|
await sharp({
|
|
create: {
|
|
width: CANVAS.width,
|
|
height: CANVAS.height,
|
|
channels: 4,
|
|
background: BACKGROUND,
|
|
},
|
|
})
|
|
.composite(composites)
|
|
.png({ compressionLevel: 9, effort: 10 })
|
|
.toFile(OUTPUT);
|
|
|
|
const kb = Math.round(fs.statSync(OUTPUT).size / 1024);
|
|
console.log(`\n🎉 Header written to docs/screenshots/header.png (${kb} KB)`);
|
|
console.log(` ${CANVAS.width}x${CANVAS.height}, composed from:`);
|
|
for (const tile of tiles) console.log(` · ${tile.file}`);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|