feat: add hero image processing and API for serving variants
- 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.
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
20
assets/images/hero-originals/README.md
Normal 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.
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: '' },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
BIN
public/images/club_about_us_2.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
public/images/hero-originals/hero1.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
public/images/hero-originals/hero2.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
public/images/hero-originals/hero3.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
public/images/hero-originals/hero4.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
public/images/hero-originals/hero5.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
public/images/hero-originals/hero6.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/images/hero-originals/hero7.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
public/images/hero-originals/hero8.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
public/images/hero/hero1/hero_1600.webp
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/images/hero/hero1/hero_960.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/images/hero/hero1/hero_fallback.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
public/images/hero/hero2/hero_1600.webp
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
public/images/hero/hero2/hero_960.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/images/hero/hero2/hero_fallback.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
public/images/hero/hero3/hero_1600.webp
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/images/hero/hero3/hero_960.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/images/hero/hero3/hero_fallback.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
public/images/hero/hero4/hero_1600.webp
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
public/images/hero/hero4/hero_960.webp
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
public/images/hero/hero4/hero_fallback.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
public/images/hero/hero5/hero_1600.webp
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
public/images/hero/hero5/hero_960.webp
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
public/images/hero/hero5/hero_fallback.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
public/images/hero/hero6/hero_1600.webp
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
public/images/hero/hero6/hero_960.webp
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
public/images/hero/hero6/hero_fallback.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/images/hero/hero7/hero_1600.webp
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
public/images/hero/hero7/hero_960.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/images/hero/hero7/hero_fallback.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
public/images/hero/hero8/hero_1600.webp
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/images/hero/hero8/hero_960.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/images/hero/hero8/hero_fallback.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
157
scripts/prepare-hero-variants.mjs
Normal 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()
|
||||||
87
server/api/hero-images.get.js
Normal 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 }
|
||||||
|
})
|
||||||