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( 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

@@ -32,7 +32,7 @@
Header always set Content-Security-Policy "frame-ancestors 'self' https://harheimertc.de https://www.harheimertc.de" 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) # 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) # Proxy alle Anfragen an Nuxt Server (Port 3100)
ProxyPreserveHost On ProxyPreserveHost On

View File

@@ -32,7 +32,7 @@
Header always set Content-Security-Policy "frame-ancestors 'self' https://harheimertc.de https://www.harheimertc.de" 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) # 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 # SPA Fallback für Nuxt.js
<Directory "/var/www/harheimertc/dist"> <Directory "/var/www/harheimertc/dist">

View File

@@ -4,12 +4,12 @@
@layer base { @layer base {
html { 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; scroll-behavior: smooth;
} }
h1, h2, h3, h4, h5, h6 { 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> <template>
<section <section
id="home" 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 --> <!-- Decorative Elements -->
<div class="absolute inset-0 z-0"> <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 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" /> <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 <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 object-[center_36%]"
width="1600"
height="900"
loading="eager" loading="eager"
fetchpriority="high" fetchpriority="high"
decoding="async" decoding="async"
@@ -26,7 +29,7 @@
</div> </div>
<!-- Content --> <!-- 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"> <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"> <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> Willkommen beim<br>
@@ -42,11 +45,101 @@
</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>
<style scoped> <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 { @keyframes fadeIn {
from { from {
opacity: 0; opacity: 0;

View File

@@ -1,7 +1,6 @@
<template> <template>
<section <section
v-if="news.length > 0" class="py-16 sm:py-20 bg-white min-h-[32rem]"
class="py-16 sm:py-20 bg-white"
> >
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16"> <div class="text-center mb-16">
@@ -14,7 +13,29 @@
</p> </p>
</div> </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 <div
class="grid gap-8" class="grid gap-8"
:class="getGridClass()" :class="getGridClass()"
@@ -43,6 +64,18 @@
</article> </article>
</div> </div>
</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> </div>
<!-- News Modal --> <!-- News Modal -->
@@ -91,13 +124,16 @@ import { Calendar, X } from 'lucide-vue-next'
const news = ref([]) const news = ref([])
const selectedNews = ref(null) const selectedNews = ref(null)
const isLoading = ref(true)
const loadNews = async () => { const loadNews = async () => {
try { try {
const response = await $fetch('/api/news-public') const response = await $fetch('/api/news-public')
news.value = response.news news.value = Array.isArray(response?.news) ? response.news : []
} catch (error) { } catch (error) {
console.error('Fehler beim Laden der öffentlichen News:', 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.' } { property: 'twitter:description', content: 'Offizielle Website des Harheimer Tischtennis-Club 1954 e.V.' }
], ],
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.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'
}
] ]
} }
}, },

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

View File

@@ -207,8 +207,31 @@
</div> </div>
</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 <template
v-for="section in enabledSections" v-for="section in remainingWidgetSections"
:key="section.key" :key="section.key"
> >
<HomeSpielplanTeamWidget <HomeSpielplanTeamWidget
@@ -226,17 +249,39 @@
</template> </template>
<script setup> <script setup>
import { computed, defineAsyncComponent, ref } from 'vue' import { computed, defineAsyncComponent, h, ref } from 'vue'
import { SlidersHorizontal, X } from 'lucide-vue-next' import { SlidersHorizontal, X } from 'lucide-vue-next'
import Hero from '~/components/Hero.vue' import Hero from '~/components/Hero.vue'
const HomeTermine = defineAsyncComponent(() => import('~/components/HomeTermine.vue'))
const Spielplan = defineAsyncComponent(() => import('~/components/Spielplan.vue')) const SectionLoadingPlaceholder = {
const PublicNews = defineAsyncComponent(() => import('~/components/PublicNews.vue')) name: 'SectionLoadingPlaceholder',
const HomeActions = defineAsyncComponent(() => import('~/components/HomeActions.vue')) render() {
const HomeTrainingTeaser = defineAsyncComponent(() => import('~/components/HomeTrainingTeaser.vue')) return h('section', { class: 'py-16 sm:py-20 bg-white' }, [
const HomeLinksTeaser = defineAsyncComponent(() => import('~/components/HomeLinksTeaser.vue')) h('div', { class: 'max-w-7xl mx-auto px-4 sm:px-6 lg:px-8' }, [
const HomeVereinsmeisterschaftenTeaser = defineAsyncComponent(() => import('~/components/HomeVereinsmeisterschaftenTeaser.vue')) h('div', { class: 'h-10 max-w-xs mx-auto rounded bg-gray-200 animate-pulse mb-8' }),
const HomeSpielplanTeamWidget = defineAsyncComponent(() => import('~/components/HomeSpielplanTeamWidget.vue')) 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: config } = await useFetch('/api/config')
const { data: authStatus } = await useFetch('/api/auth/status') 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 resolvedSections = computed(() => applyPersonalization([...sections.value], personalizedSections.value))
const enabledSections = computed(() => resolvedSections.value.filter(section => section.enabled !== false)) 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 = { const componentMap = {
banner: Hero, banner: Hero,
@@ -560,3 +610,26 @@ async function saveEditor() {
} }
} }
</script> </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'", "base-uri 'self'",
"object-src 'none'", "object-src 'none'",
`frame-ancestors ${allowedFrameAncestors}`, `frame-ancestors ${allowedFrameAncestors}`,
// Nuxt lädt Fonts ggf. von Google (siehe nuxt.config.js) "font-src 'self' data:",
"font-src 'self' https://fonts.gstatic.com data:", "style-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
// Script: Nuxt kann in Dev eval nutzen; diese CSP ist primär für Produktion gedacht. // Script: Nuxt kann in Dev eval nutzen; diese CSP ist primär für Produktion gedacht.
"script-src 'self'", "script-src 'self'",
"img-src 'self' data: blob:", "img-src 'self' data: blob:",