feat(hikes): explicit cover.jpg for the listing card

A `cover.*` image (jpg/jpeg/png/webp/heic/heif) in a hike's images/ dir
(or hike root) now always becomes the overview-card cover, overriding the
"first public route photo" heuristic. Unlike route photos it needs no
track.gpx waypoint, is always public, and is excluded from the photo strip;
alt text falls back heroAlt → title → "Titelbild". The shared responsive
encoder is extracted from processImage into encodeImageVariant so the cover
reuses it; its outputs join the orphan-cleanup keep-set.
This commit is contained in:
2026-05-23 16:09:14 +02:00
parent 8f843833e0
commit 169f8798f3
2 changed files with 86 additions and 15 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.84.1", "version": "1.85.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+85 -14
View File
@@ -415,10 +415,33 @@ async function processImage(
return { skipped: true, hash }; return { skipped: true, hash };
} }
const visibility: 'public' | 'private' = ref.visibility === 'private' ? 'private' : 'public'; const visibility: 'public' | 'private' = ref.visibility === 'private' ? 'private' : 'public';
// Public images go under `images/` (served directly by nginx). // Public images go under `images/` (served directly by nginx); private ones
// Private images go under `private/` (proxied through Node for auth check; // under `private/` (proxied through Node for the auth check, then handed off
// nginx hands them off via X-Accel-Redirect once the session is valid). // via X-Accel-Redirect). The encode itself is shared with the cover image.
const segment = visibility === 'private' ? 'private' : 'images'; const segment = visibility === 'private' ? 'private' : 'images';
const enc = await encodeImageVariant(buffer, hash, slug, segment, alt);
return { ...enc, hash, visibility };
}
/**
* Encode one already-loaded image into the responsive AVIF/WebP variant set
* (+ a thumbnail) under `<slug>/<segment>/`, named by content hash so existing
* encodes are reused and stale ones get swept. Shared by route photos
* (`processImage`) and the explicit cover image (`processCover`).
*/
async function encodeImageVariant(
buffer: Buffer,
hash: string,
slug: string,
segment: 'images' | 'private',
alt: string
): Promise<{
variant: ImageVariant;
thumbnailRelUrl: string;
largestRelUrl: string;
cached: boolean;
outNames: string[];
}> {
// Filenames are content-hash only — the source basename (which usually // Filenames are content-hash only — the source basename (which usually
// encodes a date + camera ID) is intentionally dropped so it doesn't leak // encodes a date + camera ID) is intentionally dropped so it doesn't leak
// into the published URLs. // into the published URLs.
@@ -503,13 +526,45 @@ async function processImage(
}, },
thumbnailRelUrl: thumbUrl, thumbnailRelUrl: thumbUrl,
largestRelUrl: largestWebp, largestRelUrl: largestWebp,
hash,
visibility,
cached, cached,
outNames outNames
}; };
} }
// ---------------------------------------------------------------------------
// Explicit card cover (cover.jpg / .jpeg / .png / .webp / .heic / .heif).
// When present it always wins over the "first public route photo" heuristic,
// and unlike route photos it needs no track.gpx waypoint — it's a deliberate
// listing thumbnail. Looked up in `images/` first, then the hike root.
// ---------------------------------------------------------------------------
const COVER_SOURCES = ['cover.jpg', 'cover.jpeg', 'cover.png', 'cover.webp', 'cover.heic', 'cover.heif'];
async function processCover(
slug: string,
imagesDir: string,
hikeDir: string,
alt: string
): Promise<{ variant: ImageVariant; outNames: string[] } | undefined> {
let srcPath: string | undefined;
for (const dir of [imagesDir, hikeDir]) {
for (const name of COVER_SOURCES) {
const p = path.join(dir, name);
if (await pathExists(p)) {
srcPath = p;
break;
}
}
if (srcPath) break;
}
if (!srcPath) return undefined;
const buffer = await fs.readFile(srcPath);
const hash = shortHashOfBuffer(buffer);
const enc = await encodeImageVariant(buffer, hash, slug, 'images', alt);
return { variant: enc.variant, outNames: enc.outNames };
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Per-hike icon (icon.svg / icon.png / icon.jpg / icon.jpeg / icon.webp). // Per-hike icon (icon.svg / icon.png / icon.jpg / icon.jpeg / icon.webp).
// SVG passes through verbatim; raster sources are re-encoded to a single // SVG passes through verbatim; raster sources are re-encoded to a single
@@ -1076,6 +1131,9 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
try { try {
const entries = await fs.readdir(imagesDir); const entries = await fs.readdir(imagesDir);
for (const e of entries.sort()) { for (const e of entries.sort()) {
// `cover.*` is the explicit listing thumbnail — handled by processCover,
// never a route/strip photo (and doesn't need a track.gpx waypoint).
if (/^cover\.(jpe?g|png|webp|heic|heif)$/i.test(e)) continue;
if (/\.(jpe?g|png|webp|heic|heif)$/i.test(e)) imageFiles.push(path.join(imagesDir, e)); if (/\.(jpe?g|png|webp|heic|heif)$/i.test(e)) imageFiles.push(path.join(imagesDir, e));
} }
} catch { } catch {
@@ -1140,9 +1198,10 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
for (const r of results) { for (const r of results) {
if (r.variant !== null) { if (r.variant !== null) {
// Use the first PUBLIC image as the cover. Private images must not // Fallback cover when there's no explicit `cover.*`: the first PUBLIC
// surface on the listing page (which is prerendered and served to // route photo. Private images must not surface on the listing page
// anonymous viewers). // (prerendered, served to anonymous viewers). An explicit cover.*
// overrides this below.
if (cover === null && r.visibility === 'public') cover = r.variant; if (cover === null && r.visibility === 'public') cover = r.variant;
const segment = r.visibility === 'private' ? 'private' : 'images'; const segment = r.visibility === 'private' ? 'private' : 'images';
for (const name of r.outNames) keepFiles[segment].add(name); for (const name of r.outNames) keepFiles[segment].add(name);
@@ -1156,13 +1215,20 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
? (fm.difficulty as Difficulty) ? (fm.difficulty as Difficulty)
: 'T1'; : 'T1';
// Per-route icon + pre-rendered hero map — handled here (before cleanup) // Per-route icon + pre-rendered hero map + explicit cover — handled here
// so their outNames join `keepFiles.images` and survive the orphan sweep, // (before cleanup) so their outNames join `keepFiles.images` and survive the
// while previous-build `icon.<oldhash>.*` / `hero.<oldhash>.*` files // orphan sweep, while previous-build `icon.<oldhash>.*` / `hero.<oldhash>.*`
// (different hash, not in keepFiles) get removed automatically. // files (different hash, not in keepFiles) get removed automatically.
const [iconResult, heroResult] = await Promise.all([ const coverAlt =
typeof fm.heroAlt === 'string'
? fm.heroAlt
: typeof fm.title === 'string'
? fm.title
: 'Titelbild';
const [iconResult, heroResult, coverResult] = await Promise.all([
processIcon(slug, hikeDir), processIcon(slug, hikeDir),
processHero(slug, track, bbox, imagePoints, difficulty) processHero(slug, track, bbox, imagePoints, difficulty),
processCover(slug, imagesDir, hikeDir, coverAlt)
]); ]);
if (iconResult) keepFiles.images.add(iconResult.outName); if (iconResult) keepFiles.images.add(iconResult.outName);
if (heroResult) { if (heroResult) {
@@ -1172,6 +1238,11 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
keepFiles.images.add(v.darkOutName); keepFiles.images.add(v.darkOutName);
} }
} }
// An explicit cover.* always wins over the first-public-photo fallback.
if (coverResult) {
cover = coverResult.variant;
for (const name of coverResult.outNames) keepFiles.images.add(name);
}
// Cleanup pass: drop any encoded files in either segment dir that don't // Cleanup pass: drop any encoded files in either segment dir that don't
// belong to a current image. Catches both stale hashes (deleted source // belong to a current image. Catches both stale hashes (deleted source