Merge pull request 'dev' (#40) from dev into main
Reviewed-on: #40
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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/' }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
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()
|
||||
102
server/api/hero-images.get.js
Normal 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 }
|
||||
})
|
||||
@@ -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:",
|
||||
|
||||