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:
+1
-1
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user