Merge pull request 'dev' (#40) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m5s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped

Reviewed-on: #40
This commit was merged in pull request #40.
This commit is contained in:
2026-05-31 15:14:03 +02:00
51 changed files with 589 additions and 60 deletions

View File

@@ -409,6 +409,15 @@ data class HomepageSectionConfigDto(
data class HomepageDto(
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(
val ueberUns: String = "",
val geschichte: String = "",
@@ -578,6 +587,9 @@ interface ApiService {
@GET("/api/config")
suspend fun config(): Response<ConfigResponse>
@GET("/api/hero-images")
suspend fun heroImages(): Response<HeroImagesResponse>
@PUT("/api/config")
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.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<NewsDto>,
val homepageSections: List<HomepageSectionDto>,
val heroImageUrl: String? = null,
val diagnostics: List<String> = 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<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 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,

View File

@@ -34,6 +34,7 @@ data class HomeSpielplanTeamOption(
data class HomeUiState(
val loading: Boolean = true,
val heroImageUrl: String? = null,
val termine: List<TerminDto> = emptyList(),
val spiele: List<SpielDto> = emptyList(),
val news: List<NewsDto> = 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() }

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/
# 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

View File

@@ -32,7 +32,7 @@
Header always set Content-Security-Policy "frame-ancestors 'self' https://harheimertc.de https://www.harheimertc.de"
# Optional: Vollständige Content Security Policy (zusätzlich zu frame-ancestors)
# Header always set Content-Security-Policy-Report-Only "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self' https://harheimertc.de https://www.harheimertc.de; font-src 'self' https://fonts.gstatic.com data:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self'; img-src 'self' data: blob:; connect-src 'self'"
# Header always set Content-Security-Policy-Report-Only "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self' https://harheimertc.de https://www.harheimertc.de; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data: blob:; connect-src 'self'"
# Proxy alle Anfragen an Nuxt Server (Port 3100)
ProxyPreserveHost On

View File

@@ -32,7 +32,7 @@
Header always set Content-Security-Policy "frame-ancestors 'self' https://harheimertc.de https://www.harheimertc.de"
# Optional: Vollständige Content Security Policy (zusätzlich zu frame-ancestors)
# Header always set Content-Security-Policy-Report-Only "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self' https://harheimertc.de https://www.harheimertc.de; font-src 'self' https://fonts.gstatic.com data:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self'; img-src 'self' data: blob:; connect-src 'self'"
# Header always set Content-Security-Policy-Report-Only "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self' https://harheimertc.de https://www.harheimertc.de; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data: blob:; connect-src 'self'"
# SPA Fallback für Nuxt.js
<Directory "/var/www/harheimertc/dist">

View File

@@ -4,12 +4,12 @@
@layer base {
html {
font-family: 'Inter', system-ui, sans-serif;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
scroll-behavior: smooth;
}
h1, h2, h3, h4, h5, h6 {
font-family: 'Montserrat', system-ui, sans-serif;
font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
}
}

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

@@ -1,23 +1,26 @@
<template>
<section
id="home"
class="relative min-h-full flex items-center justify-center overflow-hidden bg-gradient-to-br from-gray-50 to-gray-100"
class="hero-shell relative overflow-hidden bg-gradient-to-br from-gray-50 to-gray-100"
>
<!-- Decorative Elements -->
<div class="absolute inset-0 z-0">
<div class="absolute top-0 right-0 w-96 h-96 bg-primary-200/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-15">
<source
v-if="heroImage.mobileWebp && heroImage.desktopWebp"
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"
>
<img
src="/images/club_about_us.png"
:src="heroImage.fallback"
alt=""
aria-hidden="true"
class="w-full h-full object-cover"
class="w-full h-full object-cover object-[center_36%]"
width="1600"
height="900"
loading="eager"
fetchpriority="high"
decoding="async"
@@ -26,7 +29,7 @@
</div>
<!-- Content -->
<div class="relative z-20 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 sm:py-8">
<div class="relative z-20 max-w-7xl mx-auto">
<div class="text-center">
<h1 class="text-5xl sm:text-6xl lg:text-7xl font-display font-bold text-gray-900 mb-6 leading-tight animate-fade-in">
Willkommen beim<br>
@@ -42,11 +45,101 @@
</template>
<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 yearsSinceFounding = new Date().getFullYear() - foundingYear
</script>
<style scoped>
.hero-shell {
min-height: 430px;
}
@media (min-width: 1024px) {
.hero-shell {
min-height: 540px;
}
}
@media (min-aspect-ratio: 21/9) {
.hero-shell {
min-height: 640px;
}
}
@keyframes fadeIn {
from {
opacity: 0;

View File

@@ -1,7 +1,6 @@
<template>
<section
v-if="news.length > 0"
class="py-16 sm:py-20 bg-white"
class="py-16 sm:py-20 bg-white min-h-[32rem]"
>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
@@ -14,7 +13,29 @@
</p>
</div>
<div class="flex justify-center">
<div
v-if="isLoading"
class="grid gap-8 grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
>
<div
v-for="placeholder in 3"
:key="`news-placeholder-${placeholder}`"
class="bg-gray-50 rounded-xl p-6 border border-gray-200"
>
<div class="h-4 w-32 bg-gray-200 rounded animate-pulse mb-4" />
<div class="h-7 w-3/4 bg-gray-200 rounded animate-pulse mb-4" />
<div class="space-y-2">
<div class="h-4 w-full bg-gray-200 rounded animate-pulse" />
<div class="h-4 w-5/6 bg-gray-200 rounded animate-pulse" />
<div class="h-4 w-2/3 bg-gray-200 rounded animate-pulse" />
</div>
</div>
</div>
<div
v-else-if="news.length > 0"
class="flex justify-center"
>
<div
class="grid gap-8"
:class="getGridClass()"
@@ -43,6 +64,18 @@
</article>
</div>
</div>
<div
v-else
class="max-w-xl mx-auto text-center bg-gray-50 border border-gray-200 rounded-xl p-8"
>
<p class="text-gray-700 font-semibold mb-2">
Aktuell keine News
</p>
<p class="text-gray-600 text-sm">
Neue Vereinsnachrichten erscheinen hier automatisch.
</p>
</div>
</div>
<!-- News Modal -->
@@ -91,13 +124,16 @@ import { Calendar, X } from 'lucide-vue-next'
const news = ref([])
const selectedNews = ref(null)
const isLoading = ref(true)
const loadNews = async () => {
try {
const response = await $fetch('/api/news-public')
news.value = response.news
news.value = Array.isArray(response?.news) ? response.news : []
} catch (error) {
console.error('Fehler beim Laden der öffentlichen News:', error)
} finally {
isLoading.value = false
}
}

View File

@@ -76,27 +76,7 @@ export default defineNuxtConfig({
{ property: 'twitter:description', content: 'Offizielle Website des Harheimer Tischtennis-Club 1954 e.V.' }
],
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: '' },
{
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Montserrat:wght@700;800;900&display=swap'
}
{ rel: 'canonical', href: 'https://www.harheimertc.de/' }
]
}
},

View File

@@ -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",

View File

@@ -207,8 +207,31 @@
</div>
</div>
<template v-if="heroSection">
<component :is="getComponentForSection(heroSection.id)" />
</template>
<div
v-if="featuredWidgetSection"
class="relative z-30 px-4 sm:px-6 lg:px-8"
:class="hasHeroSection ? '-mt-44 sm:-mt-48 lg:-mt-52' : 'mt-8'"
>
<div class="featured-widget-shell max-w-6xl mx-auto rounded-2xl border border-gray-200 bg-white/25 backdrop-blur-md overflow-hidden min-h-[22rem] sm:min-h-[25rem]">
<HomeSpielplanTeamWidget
v-if="featuredWidgetSection.id === 'spielplan_team'"
:season="featuredWidgetSection.config?.season"
:team-name="featuredWidgetSection.config?.teamName"
:team-age-group="featuredWidgetSection.config?.teamAgeGroup"
/>
<component
:is="getComponentForSection(featuredWidgetSection.id)"
v-else
/>
</div>
</div>
<template
v-for="section in enabledSections"
v-for="section in remainingWidgetSections"
:key="section.key"
>
<HomeSpielplanTeamWidget
@@ -226,17 +249,39 @@
</template>
<script setup>
import { computed, defineAsyncComponent, ref } from 'vue'
import { computed, defineAsyncComponent, h, ref } from 'vue'
import { SlidersHorizontal, X } from 'lucide-vue-next'
import Hero from '~/components/Hero.vue'
const HomeTermine = defineAsyncComponent(() => import('~/components/HomeTermine.vue'))
const Spielplan = defineAsyncComponent(() => import('~/components/Spielplan.vue'))
const PublicNews = defineAsyncComponent(() => import('~/components/PublicNews.vue'))
const HomeActions = defineAsyncComponent(() => import('~/components/HomeActions.vue'))
const HomeTrainingTeaser = defineAsyncComponent(() => import('~/components/HomeTrainingTeaser.vue'))
const HomeLinksTeaser = defineAsyncComponent(() => import('~/components/HomeLinksTeaser.vue'))
const HomeVereinsmeisterschaftenTeaser = defineAsyncComponent(() => import('~/components/HomeVereinsmeisterschaftenTeaser.vue'))
const HomeSpielplanTeamWidget = defineAsyncComponent(() => import('~/components/HomeSpielplanTeamWidget.vue'))
const SectionLoadingPlaceholder = {
name: 'SectionLoadingPlaceholder',
render() {
return h('section', { class: 'py-16 sm:py-20 bg-white' }, [
h('div', { class: 'max-w-7xl mx-auto px-4 sm:px-6 lg:px-8' }, [
h('div', { class: 'h-10 max-w-xs mx-auto rounded bg-gray-200 animate-pulse mb-8' }),
h('div', { class: 'h-56 rounded-2xl bg-gray-100 animate-pulse' })
])
])
}
}
function loadHomeSection(loader) {
return defineAsyncComponent({
loader,
loadingComponent: SectionLoadingPlaceholder,
delay: 0,
suspensible: false
})
}
const HomeTermine = loadHomeSection(() => import('~/components/HomeTermine.vue'))
const Spielplan = loadHomeSection(() => import('~/components/Spielplan.vue'))
const PublicNews = loadHomeSection(() => import('~/components/PublicNews.vue'))
const HomeActions = loadHomeSection(() => import('~/components/HomeActions.vue'))
const HomeTrainingTeaser = loadHomeSection(() => import('~/components/HomeTrainingTeaser.vue'))
const HomeLinksTeaser = loadHomeSection(() => import('~/components/HomeLinksTeaser.vue'))
const HomeVereinsmeisterschaftenTeaser = loadHomeSection(() => import('~/components/HomeVereinsmeisterschaftenTeaser.vue'))
const HomeSpielplanTeamWidget = loadHomeSection(() => import('~/components/HomeSpielplanTeamWidget.vue'))
const { data: config } = await useFetch('/api/config')
const { data: authStatus } = await useFetch('/api/auth/status')
@@ -344,6 +389,11 @@ function applyPersonalization(baseSections, settingsSections) {
const resolvedSections = computed(() => applyPersonalization([...sections.value], personalizedSections.value))
const enabledSections = computed(() => resolvedSections.value.filter(section => section.enabled !== false))
const heroSection = computed(() => enabledSections.value.find(section => section.id === 'banner') || null)
const hasHeroSection = computed(() => !!heroSection.value)
const widgetSections = computed(() => enabledSections.value.filter(section => section.id !== 'banner'))
const featuredWidgetSection = computed(() => widgetSections.value[0] || null)
const remainingWidgetSections = computed(() => widgetSections.value.slice(1))
const componentMap = {
banner: Hero,
@@ -560,3 +610,26 @@ async function saveEditor() {
}
}
</script>
<style scoped>
.featured-widget-shell :deep(section),
.featured-widget-shell :deep(.bg-white),
.featured-widget-shell :deep(.bg-gray-50) {
--tw-bg-opacity: 0 !important;
background: transparent !important;
background-color: transparent !important;
}
.featured-widget-shell :deep([class*='bg-white']),
.featured-widget-shell :deep([class*='bg-gray-50']) {
--tw-bg-opacity: 0 !important;
background: transparent !important;
background-color: transparent !important;
}
.featured-widget-shell :deep(.py-16),
.featured-widget-shell :deep(.sm\:py-20) {
padding-top: 1.5rem !important;
padding-bottom: 2rem !important;
}
</style>

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,102 @@
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
}
}
function isAllowedVariantKey(key) {
return /^[A-Za-z0-9_-]+$/.test(key)
}
function appendPathSegment(rootDir, segment) {
if (!isAllowedVariantKey(segment)) return null
return `${rootDir}${path.sep}${segment}`
}
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
if (!isAllowedVariantKey(key)) continue
const variantDir = appendPathSegment(heroRoot, key)
if (!variantDir) continue
const mobileFile = 'hero_960.webp'
const desktopFile = 'hero_1600.webp'
const mobilePath = `${variantDir}${path.sep}${mobileFile}`
const desktopPath = `${variantDir}${path.sep}${desktopFile}`
if (!mobilePath || !desktopPath) continue
if (!(await fileExists(mobilePath)) || !(await fileExists(desktopPath))) {
continue
}
let fallbackFile = desktopFile
for (const candidate of FALLBACK_FILE_CANDIDATES) {
const fallbackPath = `${variantDir}${path.sep}${candidate}`
if (fallbackPath && await fileExists(fallbackPath)) {
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 }
})

View File

@@ -38,9 +38,8 @@ export default defineEventHandler((event) => {
"base-uri 'self'",
"object-src 'none'",
`frame-ancestors ${allowedFrameAncestors}`,
// Nuxt lädt Fonts ggf. von Google (siehe nuxt.config.js)
"font-src 'self' https://fonts.gstatic.com data:",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' data:",
"style-src 'self' 'unsafe-inline'",
// Script: Nuxt kann in Dev eval nutzen; diese CSP ist primär für Produktion gedacht.
"script-src 'self'",
"img-src 'self' data: blob:",