feat: add hero image processing and API for serving variants
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m44s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped

- Introduced a new script `prepare-hero-variants.mjs` to generate responsive hero image variants in WebP format.
- Added a fallback image `hero_fallback.png` for each variant.
- Created an API endpoint `hero-images.get.js` to retrieve available hero image variants and their fallback images.
- Implemented directory and file checks to ensure the existence of required images before serving.
This commit is contained in:
Torsten Schulz (local)
2026-05-31 14:07:14 +02:00
parent 7c93966878
commit 6983186caf
45 changed files with 422 additions and 28 deletions

View File

@@ -409,6 +409,15 @@ data class HomepageSectionConfigDto(
data class HomepageDto( data class HomepageDto(
val sections: List<HomepageSectionDto> = emptyList(), val sections: List<HomepageSectionDto> = emptyList(),
) )
data class HeroImageVariantDto(
val key: String = "",
val mobileWebp: String = "",
val desktopWebp: String = "",
val fallback: String = "",
)
data class HeroImagesResponse(
val variants: List<HeroImageVariantDto> = emptyList(),
)
data class SeitenDto( data class SeitenDto(
val ueberUns: String = "", val ueberUns: String = "",
val geschichte: String = "", val geschichte: String = "",
@@ -578,6 +587,9 @@ interface ApiService {
@GET("/api/config") @GET("/api/config")
suspend fun config(): Response<ConfigResponse> suspend fun config(): Response<ConfigResponse>
@GET("/api/hero-images")
suspend fun heroImages(): Response<HeroImagesResponse>
@PUT("/api/config") @PUT("/api/config")
suspend fun updateConfig(@Body request: ConfigResponse): Response<ConfigResponse> suspend fun updateConfig(@Body request: ConfigResponse): Response<ConfigResponse>

View File

@@ -3,12 +3,14 @@ package de.harheimertc.repositories
import de.harheimertc.BuildConfig import de.harheimertc.BuildConfig
import de.harheimertc.data.ApiService import de.harheimertc.data.ApiService
import de.harheimertc.data.HomepageSectionDto import de.harheimertc.data.HomepageSectionDto
import de.harheimertc.data.HeroImageVariantDto
import de.harheimertc.data.NewsDto import de.harheimertc.data.NewsDto
import de.harheimertc.data.SeasonDto import de.harheimertc.data.SeasonDto
import de.harheimertc.data.SpielDto import de.harheimertc.data.SpielDto
import de.harheimertc.data.SpielplanResponse import de.harheimertc.data.SpielplanResponse
import de.harheimertc.data.TerminDto import de.harheimertc.data.TerminDto
import io.sentry.Sentry import io.sentry.Sentry
import kotlin.random.Random
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -19,6 +21,7 @@ data class HomeData(
val selectedSpielplanSeason: String?, val selectedSpielplanSeason: String?,
val news: List<NewsDto>, val news: List<NewsDto>,
val homepageSections: List<HomepageSectionDto>, val homepageSections: List<HomepageSectionDto>,
val heroImageUrl: String? = null,
val diagnostics: List<String> = emptyList(), val diagnostics: List<String> = emptyList(),
) )
@@ -135,6 +138,35 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
) )
} }
}.getOrDefault(emptyList()) }.getOrDefault(emptyList())
val heroImageUrl = runCatching {
val response = api.heroImages()
if (!response.isSuccessful) {
val errorBody = response.errorBody()?.string().orEmpty()
diagnostics += buildDiagnostic(
endpoint = "GET /api/hero-images",
requestPayload = "none",
httpCode = response.code(),
responseBody = errorBody,
throwable = null,
)
error("Hero-Bilder konnten nicht geladen werden (HTTP ${response.code()}).")
}
val variants = response.body()?.variants.orEmpty()
pickRandomHeroImage(variants)
}.onFailure { error ->
captureLoadIssue("fetchHomeData.heroImages", error)
if (diagnostics.none { it.contains("GET /api/hero-images") }) {
diagnostics += buildDiagnostic(
endpoint = "GET /api/hero-images",
requestPayload = "none",
httpCode = null,
responseBody = null,
throwable = error,
)
}
}.getOrNull()
HomeData( HomeData(
termine = termine, termine = termine,
spiele = spiele, spiele = spiele,
@@ -142,6 +174,7 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
selectedSpielplanSeason = spielplanResponse?.season, selectedSpielplanSeason = spielplanResponse?.season,
news = news, news = news,
homepageSections = homepageSections, homepageSections = homepageSections,
heroImageUrl = heroImageUrl,
diagnostics = diagnostics, diagnostics = diagnostics,
) )
}.onFailure { error -> }.onFailure { error ->
@@ -194,4 +227,22 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
append("Throwable: ").append(throwableInfo) append("Throwable: ").append(throwableInfo)
} }
} }
private fun pickRandomHeroImage(variants: List<HeroImageVariantDto>): String? {
if (variants.isEmpty()) return null
val valid = variants.filter { it.fallback.isNotBlank() }
if (valid.isEmpty()) return null
val selected = valid[Random.nextInt(valid.size)]
return toAbsoluteUrl(selected.fallback)
}
private fun toAbsoluteUrl(pathOrUrl: String): String {
if (pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")) {
return pathOrUrl
}
val base = BuildConfig.API_BASE_URL.trimEnd('/')
val path = if (pathOrUrl.startsWith('/')) pathOrUrl else "/$pathOrUrl"
return "$base$path"
}
} }

View File

@@ -53,7 +53,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
import de.harheimertc.ui.navigation.NavigationViewModel import de.harheimertc.ui.navigation.NavigationViewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import coil.compose.AsyncImage import coil.compose.AsyncImage
import de.harheimertc.BuildConfig
import de.harheimertc.data.HomepageSectionDto import de.harheimertc.data.HomepageSectionDto
import de.harheimertc.data.NewsDto import de.harheimertc.data.NewsDto
import de.harheimertc.data.SeasonDto import de.harheimertc.data.SeasonDto
@@ -172,7 +171,9 @@ fun HomeScreen(
if (!section.enabled) return@forEachIndexed if (!section.enabled) return@forEachIndexed
val sectionKey = homeSectionKey(section) val sectionKey = homeSectionKey(section)
when (section.id) { when (section.id) {
"banner" -> item(key = "home_section_${sectionKey}_$index") { WebHero() } "banner" -> item(key = "home_section_${sectionKey}_$index") {
WebHero(imageUrl = state.heroImageUrl)
}
"termine" -> item(key = "home_section_${sectionKey}_$index") { "termine" -> item(key = "home_section_${sectionKey}_$index") {
HomeTermineSection( HomeTermineSection(
termine = state.termine, termine = state.termine,
@@ -496,7 +497,7 @@ private fun HomeSpielplanTeamWidgetSection(
} }
@Composable @Composable
private fun WebHero() { private fun WebHero(imageUrl: String?) {
val years = Calendar.getInstance().get(Calendar.YEAR) - 1954 val years = Calendar.getInstance().get(Calendar.YEAR) - 1954
Box( Box(
modifier = Modifier modifier = Modifier
@@ -505,12 +506,14 @@ private fun WebHero() {
.background(Brush.verticalGradient(listOf(Color(0xFFFAFAFA), Color(0xFFF4F4F5)))), .background(Brush.verticalGradient(listOf(Color(0xFFFAFAFA), Color(0xFFF4F4F5)))),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
AsyncImage( if (!imageUrl.isNullOrBlank()) {
model = "${BuildConfig.API_BASE_URL}images/club_about_us.png", AsyncImage(
contentDescription = null, model = imageUrl,
modifier = Modifier.matchParentSize().alpha(0.10f), contentDescription = null,
contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize().alpha(0.10f),
) contentScale = ContentScale.Crop,
)
}
Column( Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 58.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 58.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,

View File

@@ -34,6 +34,7 @@ data class HomeSpielplanTeamOption(
data class HomeUiState( data class HomeUiState(
val loading: Boolean = true, val loading: Boolean = true,
val heroImageUrl: String? = null,
val termine: List<TerminDto> = emptyList(), val termine: List<TerminDto> = emptyList(),
val spiele: List<SpielDto> = emptyList(), val spiele: List<SpielDto> = emptyList(),
val news: List<NewsDto> = emptyList(), val news: List<NewsDto> = emptyList(),
@@ -87,6 +88,7 @@ class HomeViewModel @Inject constructor(
) )
_state.value = HomeUiState( _state.value = HomeUiState(
loading = false, loading = false,
heroImageUrl = data.heroImageUrl,
termine = data.termine termine = data.termine
.filter { it.asDateTime()?.isBefore(LocalDateTime.now()) != true } .filter { it.asDateTime()?.isBefore(LocalDateTime.now()) != true }
.sortedBy { it.asDateTime() } .sortedBy { it.asDateTime() }

File diff suppressed because one or more lines are too long

View File

@@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/
PRODUCTION_API_BASE_URL=https://harheimertc.de/ PRODUCTION_API_BASE_URL=https://harheimertc.de/
# Android app versioning for Play Store uploads # Android app versioning for Play Store uploads
ANDROID_VERSION_CODE=18 ANDROID_VERSION_CODE=19
ANDROID_VERSION_NAME=0.9.13 ANDROID_VERSION_NAME=0.9.14
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping. # Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
RELEASE_MINIFY_ENABLED=false RELEASE_MINIFY_ENABLED=false

View File

@@ -0,0 +1,20 @@
# Hero-Originalbilder
Das Tool `npm run hero:prepare` unterstuetzt zwei Eingabeformate:
1. Unterordner pro Variante (empfohlen)
- `assets/images/hero-originals/<variante>/hero.png`
2. Flache Ablage von PNGs
- `public/images/hero-originals/hero1.png`
- `public/images/hero-originals/hero2.png`
Ausgabe je Variante in:
- `public/images/hero/<variante>/hero_960.webp`
- `public/images/hero/<variante>/hero_1600.webp`
- `public/images/hero/<variante>/hero_fallback.png`
Die Startseite (`components/Hero.vue`) waehlt danach automatisch zufaellig eine vorhandene Variante aus.

View File

@@ -9,12 +9,13 @@
<div class="absolute bottom-0 left-0 w-96 h-96 bg-gray-300/30 rounded-full blur-3xl" /> <div class="absolute bottom-0 left-0 w-96 h-96 bg-gray-300/30 rounded-full blur-3xl" />
<picture class="absolute inset-0 opacity-10"> <picture class="absolute inset-0 opacity-10">
<source <source
v-if="heroImage.mobileWebp && heroImage.desktopWebp"
type="image/webp" type="image/webp"
srcset="/images/club_about_us_hero_960.webp 960w, /images/club_about_us_hero_1600.webp 1600w" :srcset="`${heroImage.mobileWebp} 960w, ${heroImage.desktopWebp} 1600w`"
sizes="(max-width: 1024px) 960px, 1600px" sizes="(max-width: 1024px) 960px, 1600px"
> >
<img <img
src="/images/club_about_us.png" :src="heroImage.fallback"
alt="" alt=""
aria-hidden="true" aria-hidden="true"
class="w-full h-full object-cover" class="w-full h-full object-cover"
@@ -42,6 +43,80 @@
</template> </template>
<script setup> <script setup>
import { computed } from 'vue'
import { useFetch, useHead, useState } from '#imports'
function buildInlineFallback() {
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" preserveAspectRatio="xMidYMid slice">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#eef2f7" />
<stop offset="100%" stop-color="#d8e0ea" />
</linearGradient>
</defs>
<rect width="1600" height="900" fill="url(#g)" />
</svg>`
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`
}
const DEFAULT_HERO_IMAGE = {
key: 'fallback',
mobileWebp: '',
desktopWebp: '',
fallback: buildInlineFallback()
}
const { data: heroImagesResponse } = await useFetch('/api/hero-images')
const heroVariants = computed(() => {
const variants = heroImagesResponse.value?.variants
return Array.isArray(variants) && variants.length ? variants : [DEFAULT_HERO_IMAGE]
})
function pickRandomHeroImage(variants) {
const list = Array.isArray(variants) && variants.length ? variants : [DEFAULT_HERO_IMAGE]
const index = Math.floor(Math.random() * list.length)
return list[index]
}
const heroImageState = useState('home-hero-image', () => pickRandomHeroImage(heroVariants.value))
if (!heroVariants.value.some((variant) => variant.key === heroImageState.value?.key)) {
heroImageState.value = pickRandomHeroImage(heroVariants.value)
}
const heroImage = computed(() => heroImageState.value)
const preloadLinks = computed(() => {
const links = []
if (heroImage.value.mobileWebp) {
links.push({
rel: 'preload',
as: 'image',
href: heroImage.value.mobileWebp,
type: 'image/webp',
media: '(max-width: 1024px)'
})
}
if (heroImage.value.desktopWebp) {
links.push({
rel: 'preload',
as: 'image',
href: heroImage.value.desktopWebp,
type: 'image/webp',
media: '(min-width: 1025px)'
})
}
return links
})
useHead(() => ({
link: preloadLinks.value
}))
const foundingYear = 1954 const foundingYear = 1954
const yearsSinceFounding = new Date().getFullYear() - foundingYear const yearsSinceFounding = new Date().getFullYear() - foundingYear
</script> </script>

View File

@@ -77,20 +77,6 @@ export default defineNuxtConfig({
], ],
link: [ link: [
{ rel: 'canonical', href: 'https://www.harheimertc.de/' }, { rel: 'canonical', href: 'https://www.harheimertc.de/' },
{
rel: 'preload',
as: 'image',
href: '/images/club_about_us_hero_960.webp',
type: 'image/webp',
media: '(max-width: 1024px)'
},
{
rel: 'preload',
as: 'image',
href: '/images/club_about_us_hero_1600.webp',
type: 'image/webp',
media: '(min-width: 1025px)'
},
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' }, { rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' }, { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
{ {

View File

@@ -19,6 +19,7 @@
"check-security": "node scripts/verify-no-public-writes.js", "check-security": "node scripts/verify-no-public-writes.js",
"smoke-local": "BASE_URL=http://127.0.0.1:3100 node scripts/smoke-tests.js", "smoke-local": "BASE_URL=http://127.0.0.1:3100 node scripts/smoke-tests.js",
"sync-public-data": "node scripts/sync-public-data.js", "sync-public-data": "node scripts/sync-public-data.js",
"hero:prepare": "node scripts/prepare-hero-variants.mjs",
"import-spielplan": "node scripts/import-spielplan.js", "import-spielplan": "node scripts/import-spielplan.js",
"publish-spielplan": "node scripts/publish-imported-spielplan.js", "publish-spielplan": "node scripts/publish-imported-spielplan.js",
"playstore:assets": "./scripts/playstore-assets.sh", "playstore:assets": "./scripts/playstore-assets.sh",

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@@ -0,0 +1,157 @@
#!/usr/bin/env node
import { promises as fs } from 'fs'
import path from 'path'
import sharp from 'sharp'
const cwd = process.cwd()
const outputRoot = path.join(cwd, 'public', 'images', 'hero')
const inputRoots = [
path.join(cwd, 'assets', 'images', 'hero-originals'),
path.join(cwd, 'public', 'images', 'hero-originals')
]
const OUTPUT_VARIANTS = [
{ width: 960, height: 540, quality: 66, fileName: 'hero_960.webp' },
{ width: 1600, height: 900, quality: 72, fileName: 'hero_1600.webp' }
]
async function ensureDir(dirPath) {
await fs.mkdir(dirPath, { recursive: true })
}
async function listSubdirs(rootPath) {
const entries = await fs.readdir(rootPath, { withFileTypes: true })
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name)
}
async function listFiles(rootPath) {
const entries = await fs.readdir(rootPath, { withFileTypes: true })
return entries.filter((entry) => entry.isFile()).map((entry) => entry.name)
}
function normalizeVariantName(name) {
return String(name || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '') || 'hero'
}
function pickSourcePng(files) {
const pngFiles = files.filter((file) => /\.png$/i.test(file))
if (!pngFiles.length) return null
const prioritized = ['hero.png', 'hero-original.png', 'original.png']
for (const name of prioritized) {
const found = pngFiles.find((file) => file.toLowerCase() === name)
if (found) return found
}
return pngFiles.sort((a, b) => a.localeCompare(b, 'de'))[0]
}
async function convertVariant(sourcePath, targetDir, variant) {
const targetPath = path.join(targetDir, variant.fileName)
await sharp(sourcePath)
.resize(variant.width, variant.height, { fit: 'cover', position: 'centre' })
.webp({ quality: variant.quality })
.toFile(targetPath)
}
async function collectVariantSources(rootPath) {
const sources = []
const subdirs = await listSubdirs(rootPath)
for (const dirName of subdirs.sort((a, b) => a.localeCompare(b, 'de'))) {
const dirPath = path.join(rootPath, dirName)
const files = await fs.readdir(dirPath)
const sourceFile = pickSourcePng(files)
if (!sourceFile) continue
sources.push({
variant: normalizeVariantName(dirName),
sourcePath: path.join(dirPath, sourceFile)
})
}
if (sources.length) return sources
const rootFiles = await listFiles(rootPath)
const pngFiles = rootFiles.filter((file) => /\.png$/i.test(file)).sort((a, b) => a.localeCompare(b, 'de'))
for (const fileName of pngFiles) {
sources.push({
variant: normalizeVariantName(path.parse(fileName).name),
sourcePath: path.join(rootPath, fileName)
})
}
return sources
}
async function findInputRootWithSources() {
for (const rootPath of inputRoots) {
try {
const stats = await fs.stat(rootPath)
if (!stats.isDirectory()) continue
const sources = await collectVariantSources(rootPath)
if (sources.length) {
return { rootPath, sources }
}
} catch {
// ignore missing roots
}
}
return null
}
async function processVariantSource(source) {
const targetDir = path.join(outputRoot, source.variant)
await ensureDir(targetDir)
for (const variant of OUTPUT_VARIANTS) {
await convertVariant(source.sourcePath, targetDir, variant)
}
await fs.copyFile(source.sourcePath, path.join(targetDir, 'hero_fallback.png'))
return {
variant: source.variant,
source: path.relative(cwd, source.sourcePath),
target: path.relative(cwd, targetDir)
}
}
async function main() {
try {
await ensureDir(outputRoot)
const input = await findInputRootWithSources()
if (!input) {
console.error('Keine Hero-Quellen gefunden.')
console.error('Unterstuetzte Pfade:')
console.error(`- ${inputRoots[0]}/<variante>/*.png`)
console.error(`- ${inputRoots[1]}/*.png`)
process.exit(1)
}
const results = []
for (const source of input.sources) {
const result = await processVariantSource(source)
results.push(result)
}
console.log(`Input-Quelle: ${path.relative(cwd, input.rootPath)}`)
console.log('Hero-Varianten erfolgreich erstellt:')
for (const result of results) {
console.log(`- ${result.variant}: ${result.source} -> ${result.target}`)
}
} catch (error) {
console.error('Fehler bei der Hero-Bildaufbereitung:', error)
process.exit(1)
}
}
await main()

View File

@@ -0,0 +1,87 @@
import { promises as fs } from 'fs'
import path from 'path'
const HERO_ROOT_CANDIDATES = [
path.join(process.cwd(), 'public', 'images', 'hero'),
path.join(process.cwd(), '.output', 'public', 'images', 'hero'),
path.join(process.cwd(), '..', 'public', 'images', 'hero'),
path.join(process.cwd(), '..', '.output', 'public', 'images', 'hero')
]
const FALLBACK_FILE_CANDIDATES = [
'hero_fallback.webp',
'hero_fallback.jpg',
'hero_fallback.jpeg',
'hero_fallback.png'
]
async function findExistingDir(paths) {
for (const candidate of paths) {
try {
const stats = await fs.stat(candidate)
if (stats.isDirectory()) return candidate
} catch {
// ignore
}
}
return null
}
async function fileExists(filePath) {
try {
const stats = await fs.stat(filePath)
return stats.isFile()
} catch {
return false
}
}
async function listHeroVariants(heroRoot) {
const dirEntries = await fs.readdir(heroRoot, { withFileTypes: true })
const variants = []
for (const entry of dirEntries) {
if (!entry.isDirectory()) continue
const key = entry.name
const variantDir = path.join(heroRoot, key)
const mobileFile = 'hero_960.webp'
const desktopFile = 'hero_1600.webp'
const mobilePath = path.join(variantDir, mobileFile)
const desktopPath = path.join(variantDir, desktopFile)
if (!(await fileExists(mobilePath)) || !(await fileExists(desktopPath))) {
continue
}
let fallbackFile = desktopFile
for (const candidate of FALLBACK_FILE_CANDIDATES) {
if (await fileExists(path.join(variantDir, candidate))) {
fallbackFile = candidate
break
}
}
variants.push({
key,
mobileWebp: `/images/hero/${key}/${mobileFile}`,
desktopWebp: `/images/hero/${key}/${desktopFile}`,
fallback: `/images/hero/${key}/${fallbackFile}`
})
}
return variants.sort((a, b) => a.key.localeCompare(b.key, 'de'))
}
export default defineEventHandler(async (event) => {
const heroRoot = await findExistingDir(HERO_ROOT_CANDIDATES)
if (!heroRoot) {
return { variants: [] }
}
const variants = await listHeroVariants(heroRoot)
setHeader(event, 'Cache-Control', 'public, max-age=300, stale-while-revalidate=600')
return { variants }
})