diff --git a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt index d0c393a..409cf76 100644 --- a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt +++ b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt @@ -409,6 +409,15 @@ data class HomepageSectionConfigDto( data class HomepageDto( val sections: List = emptyList(), ) +data class HeroImageVariantDto( + val key: String = "", + val mobileWebp: String = "", + val desktopWebp: String = "", + val fallback: String = "", +) +data class HeroImagesResponse( + val variants: List = emptyList(), +) data class SeitenDto( val ueberUns: String = "", val geschichte: String = "", @@ -578,6 +587,9 @@ interface ApiService { @GET("/api/config") suspend fun config(): Response + @GET("/api/hero-images") + suspend fun heroImages(): Response + @PUT("/api/config") suspend fun updateConfig(@Body request: ConfigResponse): Response diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/HomeRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/HomeRepository.kt index d568142..69927fc 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/HomeRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/HomeRepository.kt @@ -3,12 +3,14 @@ package de.harheimertc.repositories import de.harheimertc.BuildConfig import de.harheimertc.data.ApiService import de.harheimertc.data.HomepageSectionDto +import de.harheimertc.data.HeroImageVariantDto import de.harheimertc.data.NewsDto import de.harheimertc.data.SeasonDto import de.harheimertc.data.SpielDto import de.harheimertc.data.SpielplanResponse import de.harheimertc.data.TerminDto import io.sentry.Sentry +import kotlin.random.Random import javax.inject.Inject import javax.inject.Singleton @@ -19,6 +21,7 @@ data class HomeData( val selectedSpielplanSeason: String?, val news: List, val homepageSections: List, + val heroImageUrl: String? = null, val diagnostics: List = emptyList(), ) @@ -135,6 +138,35 @@ class HomeRepository @Inject constructor(private val api: ApiService) { ) } }.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( termine = termine, spiele = spiele, @@ -142,6 +174,7 @@ class HomeRepository @Inject constructor(private val api: ApiService) { selectedSpielplanSeason = spielplanResponse?.season, news = news, homepageSections = homepageSections, + heroImageUrl = heroImageUrl, diagnostics = diagnostics, ) }.onFailure { error -> @@ -194,4 +227,22 @@ class HomeRepository @Inject constructor(private val api: ApiService) { append("Throwable: ").append(throwableInfo) } } + + private fun pickRandomHeroImage(variants: List): 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" + } } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt index ab47356..409eab2 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt @@ -53,7 +53,6 @@ import androidx.hilt.navigation.compose.hiltViewModel import de.harheimertc.ui.navigation.NavigationViewModel import androidx.navigation.NavController import coil.compose.AsyncImage -import de.harheimertc.BuildConfig import de.harheimertc.data.HomepageSectionDto import de.harheimertc.data.NewsDto import de.harheimertc.data.SeasonDto @@ -172,7 +171,9 @@ fun HomeScreen( if (!section.enabled) return@forEachIndexed val sectionKey = homeSectionKey(section) 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") { HomeTermineSection( termine = state.termine, @@ -496,7 +497,7 @@ private fun HomeSpielplanTeamWidgetSection( } @Composable -private fun WebHero() { +private fun WebHero(imageUrl: String?) { val years = Calendar.getInstance().get(Calendar.YEAR) - 1954 Box( modifier = Modifier @@ -505,12 +506,14 @@ private fun WebHero() { .background(Brush.verticalGradient(listOf(Color(0xFFFAFAFA), Color(0xFFF4F4F5)))), contentAlignment = Alignment.Center, ) { - AsyncImage( - model = "${BuildConfig.API_BASE_URL}images/club_about_us.png", - contentDescription = null, - modifier = Modifier.matchParentSize().alpha(0.10f), - contentScale = ContentScale.Crop, - ) + if (!imageUrl.isNullOrBlank()) { + AsyncImage( + model = imageUrl, + contentDescription = null, + modifier = Modifier.matchParentSize().alpha(0.10f), + contentScale = ContentScale.Crop, + ) + } Column( modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 58.dp), horizontalAlignment = Alignment.CenterHorizontally, diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeViewModel.kt index 65f3c3f..8a6279c 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeViewModel.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeViewModel.kt @@ -34,6 +34,7 @@ data class HomeSpielplanTeamOption( data class HomeUiState( val loading: Boolean = true, + val heroImageUrl: String? = null, val termine: List = emptyList(), val spiele: List = emptyList(), val news: List = emptyList(), @@ -87,6 +88,7 @@ class HomeViewModel @Inject constructor( ) _state.value = HomeUiState( loading = false, + heroImageUrl = data.heroImageUrl, termine = data.termine .filter { it.asDateTime()?.isBefore(LocalDateTime.now()) != true } .sortedBy { it.asDateTime() } diff --git a/android-app/build/reports/problems/problems-report.html b/android-app/build/reports/problems/problems-report.html index 24a37cc..77cfa1f 100644 --- a/android-app/build/reports/problems/problems-report.html +++ b/android-app/build/reports/problems/problems-report.html @@ -653,7 +653,7 @@ code + .copy-button { diff --git a/android-app/gradle.properties b/android-app/gradle.properties index 77403df..acf7727 100644 --- a/android-app/gradle.properties +++ b/android-app/gradle.properties @@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/ PRODUCTION_API_BASE_URL=https://harheimertc.de/ # Android app versioning for Play Store uploads -ANDROID_VERSION_CODE=18 -ANDROID_VERSION_NAME=0.9.13 +ANDROID_VERSION_CODE=19 +ANDROID_VERSION_NAME=0.9.14 # Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping. RELEASE_MINIFY_ENABLED=false diff --git a/assets/images/hero-originals/README.md b/assets/images/hero-originals/README.md new file mode 100644 index 0000000..3c4958b --- /dev/null +++ b/assets/images/hero-originals/README.md @@ -0,0 +1,20 @@ +# Hero-Originalbilder + +Das Tool `npm run hero:prepare` unterstuetzt zwei Eingabeformate: + +1. Unterordner pro Variante (empfohlen) + +- `assets/images/hero-originals//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//hero_960.webp` +- `public/images/hero//hero_1600.webp` +- `public/images/hero//hero_fallback.png` + +Die Startseite (`components/Hero.vue`) waehlt danach automatisch zufaellig eine vorhandene Variante aus. diff --git a/components/Hero.vue b/components/Hero.vue index cd54fe2..4251821 100644 --- a/components/Hero.vue +++ b/components/Hero.vue @@ -9,12 +9,13 @@
diff --git a/nuxt.config.js b/nuxt.config.js index ac165a4..80710ff 100644 --- a/nuxt.config.js +++ b/nuxt.config.js @@ -77,20 +77,6 @@ export default defineNuxtConfig({ ], link: [ { 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.gstatic.com', crossorigin: '' }, { diff --git a/package.json b/package.json index 843aca5..44b67c4 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "check-security": "node scripts/verify-no-public-writes.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", + "hero:prepare": "node scripts/prepare-hero-variants.mjs", "import-spielplan": "node scripts/import-spielplan.js", "publish-spielplan": "node scripts/publish-imported-spielplan.js", "playstore:assets": "./scripts/playstore-assets.sh", diff --git a/public/images/club_about_us_2.png b/public/images/club_about_us_2.png new file mode 100644 index 0000000..fdcda76 Binary files /dev/null and b/public/images/club_about_us_2.png differ diff --git a/public/images/hero-originals/hero1.png b/public/images/hero-originals/hero1.png new file mode 100644 index 0000000..fdcda76 Binary files /dev/null and b/public/images/hero-originals/hero1.png differ diff --git a/public/images/hero-originals/hero2.png b/public/images/hero-originals/hero2.png new file mode 100644 index 0000000..f31b935 Binary files /dev/null and b/public/images/hero-originals/hero2.png differ diff --git a/public/images/hero-originals/hero3.png b/public/images/hero-originals/hero3.png new file mode 100644 index 0000000..c108d37 Binary files /dev/null and b/public/images/hero-originals/hero3.png differ diff --git a/public/images/hero-originals/hero4.png b/public/images/hero-originals/hero4.png new file mode 100644 index 0000000..747d8dd Binary files /dev/null and b/public/images/hero-originals/hero4.png differ diff --git a/public/images/hero-originals/hero5.png b/public/images/hero-originals/hero5.png new file mode 100644 index 0000000..496e4c7 Binary files /dev/null and b/public/images/hero-originals/hero5.png differ diff --git a/public/images/hero-originals/hero6.png b/public/images/hero-originals/hero6.png new file mode 100644 index 0000000..61236fc Binary files /dev/null and b/public/images/hero-originals/hero6.png differ diff --git a/public/images/hero-originals/hero7.png b/public/images/hero-originals/hero7.png new file mode 100644 index 0000000..9beb13d Binary files /dev/null and b/public/images/hero-originals/hero7.png differ diff --git a/public/images/hero-originals/hero8.png b/public/images/hero-originals/hero8.png new file mode 100644 index 0000000..fdcda76 Binary files /dev/null and b/public/images/hero-originals/hero8.png differ diff --git a/public/images/hero/hero1/hero_1600.webp b/public/images/hero/hero1/hero_1600.webp new file mode 100644 index 0000000..2b58c07 Binary files /dev/null and b/public/images/hero/hero1/hero_1600.webp differ diff --git a/public/images/hero/hero1/hero_960.webp b/public/images/hero/hero1/hero_960.webp new file mode 100644 index 0000000..f189e17 Binary files /dev/null and b/public/images/hero/hero1/hero_960.webp differ diff --git a/public/images/hero/hero1/hero_fallback.png b/public/images/hero/hero1/hero_fallback.png new file mode 100644 index 0000000..fdcda76 Binary files /dev/null and b/public/images/hero/hero1/hero_fallback.png differ diff --git a/public/images/hero/hero2/hero_1600.webp b/public/images/hero/hero2/hero_1600.webp new file mode 100644 index 0000000..d9d151a Binary files /dev/null and b/public/images/hero/hero2/hero_1600.webp differ diff --git a/public/images/hero/hero2/hero_960.webp b/public/images/hero/hero2/hero_960.webp new file mode 100644 index 0000000..918136f Binary files /dev/null and b/public/images/hero/hero2/hero_960.webp differ diff --git a/public/images/hero/hero2/hero_fallback.png b/public/images/hero/hero2/hero_fallback.png new file mode 100644 index 0000000..f31b935 Binary files /dev/null and b/public/images/hero/hero2/hero_fallback.png differ diff --git a/public/images/hero/hero3/hero_1600.webp b/public/images/hero/hero3/hero_1600.webp new file mode 100644 index 0000000..2ebfff7 Binary files /dev/null and b/public/images/hero/hero3/hero_1600.webp differ diff --git a/public/images/hero/hero3/hero_960.webp b/public/images/hero/hero3/hero_960.webp new file mode 100644 index 0000000..fc4ceaf Binary files /dev/null and b/public/images/hero/hero3/hero_960.webp differ diff --git a/public/images/hero/hero3/hero_fallback.png b/public/images/hero/hero3/hero_fallback.png new file mode 100644 index 0000000..c108d37 Binary files /dev/null and b/public/images/hero/hero3/hero_fallback.png differ diff --git a/public/images/hero/hero4/hero_1600.webp b/public/images/hero/hero4/hero_1600.webp new file mode 100644 index 0000000..f8a8714 Binary files /dev/null and b/public/images/hero/hero4/hero_1600.webp differ diff --git a/public/images/hero/hero4/hero_960.webp b/public/images/hero/hero4/hero_960.webp new file mode 100644 index 0000000..312e301 Binary files /dev/null and b/public/images/hero/hero4/hero_960.webp differ diff --git a/public/images/hero/hero4/hero_fallback.png b/public/images/hero/hero4/hero_fallback.png new file mode 100644 index 0000000..747d8dd Binary files /dev/null and b/public/images/hero/hero4/hero_fallback.png differ diff --git a/public/images/hero/hero5/hero_1600.webp b/public/images/hero/hero5/hero_1600.webp new file mode 100644 index 0000000..f48e20d Binary files /dev/null and b/public/images/hero/hero5/hero_1600.webp differ diff --git a/public/images/hero/hero5/hero_960.webp b/public/images/hero/hero5/hero_960.webp new file mode 100644 index 0000000..3f32275 Binary files /dev/null and b/public/images/hero/hero5/hero_960.webp differ diff --git a/public/images/hero/hero5/hero_fallback.png b/public/images/hero/hero5/hero_fallback.png new file mode 100644 index 0000000..496e4c7 Binary files /dev/null and b/public/images/hero/hero5/hero_fallback.png differ diff --git a/public/images/hero/hero6/hero_1600.webp b/public/images/hero/hero6/hero_1600.webp new file mode 100644 index 0000000..ded57ac Binary files /dev/null and b/public/images/hero/hero6/hero_1600.webp differ diff --git a/public/images/hero/hero6/hero_960.webp b/public/images/hero/hero6/hero_960.webp new file mode 100644 index 0000000..9c27f90 Binary files /dev/null and b/public/images/hero/hero6/hero_960.webp differ diff --git a/public/images/hero/hero6/hero_fallback.png b/public/images/hero/hero6/hero_fallback.png new file mode 100644 index 0000000..61236fc Binary files /dev/null and b/public/images/hero/hero6/hero_fallback.png differ diff --git a/public/images/hero/hero7/hero_1600.webp b/public/images/hero/hero7/hero_1600.webp new file mode 100644 index 0000000..c58a7f6 Binary files /dev/null and b/public/images/hero/hero7/hero_1600.webp differ diff --git a/public/images/hero/hero7/hero_960.webp b/public/images/hero/hero7/hero_960.webp new file mode 100644 index 0000000..34b620d Binary files /dev/null and b/public/images/hero/hero7/hero_960.webp differ diff --git a/public/images/hero/hero7/hero_fallback.png b/public/images/hero/hero7/hero_fallback.png new file mode 100644 index 0000000..9beb13d Binary files /dev/null and b/public/images/hero/hero7/hero_fallback.png differ diff --git a/public/images/hero/hero8/hero_1600.webp b/public/images/hero/hero8/hero_1600.webp new file mode 100644 index 0000000..2b58c07 Binary files /dev/null and b/public/images/hero/hero8/hero_1600.webp differ diff --git a/public/images/hero/hero8/hero_960.webp b/public/images/hero/hero8/hero_960.webp new file mode 100644 index 0000000..f189e17 Binary files /dev/null and b/public/images/hero/hero8/hero_960.webp differ diff --git a/public/images/hero/hero8/hero_fallback.png b/public/images/hero/hero8/hero_fallback.png new file mode 100644 index 0000000..fdcda76 Binary files /dev/null and b/public/images/hero/hero8/hero_fallback.png differ diff --git a/scripts/prepare-hero-variants.mjs b/scripts/prepare-hero-variants.mjs new file mode 100644 index 0000000..ca38029 --- /dev/null +++ b/scripts/prepare-hero-variants.mjs @@ -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]}//*.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() diff --git a/server/api/hero-images.get.js b/server/api/hero-images.get.js new file mode 100644 index 0000000..09dd816 --- /dev/null +++ b/server/api/hero-images.get.js @@ -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 } +})