Merge pull request 'feat: add robots.txt and sitemap.xml routes for SEO optimization' (#39) 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 2m11s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped

Reviewed-on: #39
This commit is contained in:
2026-05-31 14:03:04 +02:00
9 changed files with 269 additions and 39 deletions

View File

@@ -1,10 +1,12 @@
package de.harheimertc.ui.components package de.harheimertc.ui.components
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -72,32 +74,62 @@ private fun CompactNavigation(
onNavigate: (String) -> Unit, onNavigate: (String) -> Unit,
navigationState: NavigationUiState = NavigationUiState(), navigationState: NavigationUiState = NavigationUiState(),
) { ) {
val section = menuSection(selectedRoute) val routeSection = menuSection(selectedRoute)
val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
val section = routeSection ?: sectionOverride.value
val subItems = submenu(section, navigationState) val subItems = submenu(section, navigationState)
var cmsExpanded = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) } var cmsExpanded = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
val navigateAndClose: (String) -> Unit = { route -> cmsExpanded.value = false; onNavigate(route) } val navigateAndClose: (String) -> Unit = { route -> cmsExpanded.value = false; onNavigate(route) }
val mainScroll = rememberScrollState()
val subScroll = rememberScrollState()
val cmsSubScroll = rememberScrollState()
BrandRow(onLogin = { onNavigate(Destinations.Login.route) }) BrandRow(onLogin = { onNavigate(Destinations.Login.route) })
Row( Box(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.horizontalScroll(rememberScrollState()), Row(
horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.horizontalScroll(mainScroll),
) { horizontalArrangement = Arrangement.spacedBy(4.dp),
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate) ) {
CompactLink("Verein", Destinations.VereinAbout.route, selectedRoute, onNavigate) CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate) { sectionOverride.value = null }
CompactLink("Mannschaften", Destinations.Mannschaften.route, selectedRoute, onNavigate) CompactSectionLink("Verein", MenuSection.VEREIN, section) { sectionOverride.value = MenuSection.VEREIN }
CompactLink("Training", Destinations.Training.route, selectedRoute, onNavigate) CompactSectionLink("Mannschaften", MenuSection.MANNSCHAFTEN, section) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate) CompactSectionLink("Training", MenuSection.TRAINING, section) { sectionOverride.value = MenuSection.TRAINING }
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate) CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate) { sectionOverride.value = null }
CompactLink("Newsletter", Destinations.NewsletterSubscribe.route, selectedRoute, onNavigate) CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
if (navigationState.showGallery) { CompactSectionLink("Newsletter", MenuSection.NEWSLETTER, section) { sectionOverride.value = MenuSection.NEWSLETTER }
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate) if (navigationState.showGallery) {
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.VEREIN }
}
if (navigationState.loggedIn) {
CompactSectionLink("Intern", MenuSection.INTERN, section) { sectionOverride.value = MenuSection.INTERN }
}
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate) { sectionOverride.value = null }
if (navigationState.isAdmin || navigationState.canAccessNewsletter || navigationState.canAccessContactRequests) {
CompactSectionLink("CMS", MenuSection.INTERN, section) { sectionOverride.value = MenuSection.INTERN }
}
} }
if (navigationState.loggedIn) {
CompactLink("Intern", Destinations.MemberArea.route, selectedRoute, onNavigate) if (mainScroll.canScrollBackward) {
Text(
"",
color = Color(0xFFD4D4D8),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier
.align(Alignment.CenterStart)
.background(Color(0x66000000), RoundedCornerShape(8.dp))
.padding(horizontal = 4.dp, vertical = 2.dp),
)
} }
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate) if (mainScroll.canScrollForward) {
if (navigationState.isAdmin || navigationState.canAccessNewsletter || navigationState.canAccessContactRequests) { Text(
CompactLink("CMS", Destinations.Cms.route, selectedRoute, onNavigate) "",
color = Color(0xFFD4D4D8),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier
.align(Alignment.CenterEnd)
.background(Color(0x66000000), RoundedCornerShape(8.dp))
.padding(horizontal = 4.dp, vertical = 2.dp),
)
} }
} }
@@ -110,7 +142,7 @@ private fun CompactNavigation(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.horizontalScroll(rememberScrollState()) .horizontalScroll(subScroll)
.padding(top = 3.dp), .padding(top = 3.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
@@ -126,12 +158,15 @@ private fun CompactNavigation(
} }
} }
} }
if (subItems.isNotEmpty()) {
ScrollHintRow(subScroll)
}
if (cmsExpanded.value && cmsChildren.isNotEmpty()) { if (cmsExpanded.value && cmsChildren.isNotEmpty()) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.horizontalScroll(rememberScrollState()) .horizontalScroll(cmsSubScroll)
.padding(top = 6.dp, bottom = 3.dp), .padding(top = 6.dp, bottom = 3.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
@@ -139,6 +174,31 @@ private fun CompactNavigation(
SubLink(child.label, child.route == selectedRoute) { onNavigate(child.route) } SubLink(child.label, child.route == selectedRoute) { onNavigate(child.route) }
} }
} }
ScrollHintRow(cmsSubScroll)
}
}
@Composable
private fun CompactSectionLink(
label: String,
section: MenuSection,
activeSection: MenuSection?,
modifier: Modifier = Modifier,
onSelect: () -> Unit,
) {
Surface(
color = if (activeSection == section) Primary600 else Color.Transparent,
shape = RoundedCornerShape(8.dp),
modifier = modifier.clickable { onSelect() },
) {
Text(
label,
color = if (activeSection == section) Color.White else Color(0xFFD4D4D8),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(vertical = 9.dp, horizontal = 2.dp),
maxLines = 1,
)
} }
} }
@@ -285,11 +345,15 @@ private fun CompactLink(
selectedRoute: String?, selectedRoute: String?,
onNavigate: (String) -> Unit, onNavigate: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
beforeNavigate: () -> Unit = {},
) { ) {
Surface( Surface(
color = if (route == selectedRoute) Primary600 else Color.Transparent, color = if (route == selectedRoute) Primary600 else Color.Transparent,
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
modifier = modifier.clickable { onNavigate(route) }, modifier = modifier.clickable {
beforeNavigate()
onNavigate(route)
},
) { ) {
Text( Text(
label, label,
@@ -302,6 +366,30 @@ private fun CompactLink(
} }
} }
@Composable
private fun ScrollHintRow(scrollState: ScrollState) {
if (!scrollState.canScrollBackward && !scrollState.canScrollForward) return
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
if (scrollState.canScrollBackward) "" else "",
color = Color(0xFFD4D4D8),
style = MaterialTheme.typography.labelSmall,
)
Text(
if (scrollState.canScrollForward) "" else "",
color = Color(0xFFD4D4D8),
style = MaterialTheme.typography.labelSmall,
)
}
}
@Composable @Composable
private fun SubLink(label: String, selected: Boolean, onClick: () -> Unit) { private fun SubLink(label: String, selected: Boolean, onClick: () -> Unit) {
Surface( Surface(

View File

@@ -5,11 +5,11 @@ org.gradle.workers.max=2
LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/
# Production backend for Play Store build variant # Production backend for Play Store build variant
PRODUCTION_API_BASE_URL=https://harheimertc.tsschulz.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=17 ANDROID_VERSION_CODE=18
ANDROID_VERSION_NAME=0.9.12 ANDROID_VERSION_NAME=0.9.13
# 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

@@ -7,11 +7,22 @@
<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" />
<!-- Hintergrundbild --> <picture class="absolute inset-0 opacity-10">
<div <source
class="absolute inset-0 opacity-10" type="image/webp"
style="background-image: url('/images/club_about_us.png'); background-size: cover; background-position: center;" srcset="/images/club_about_us_hero_960.webp 960w, /images/club_about_us_hero_1600.webp 1600w"
/> sizes="(max-width: 1024px) 960px, 1600px"
>
<img
src="/images/club_about_us.png"
alt=""
aria-hidden="true"
class="w-full h-full object-cover"
loading="eager"
fetchpriority="high"
decoding="async"
>
</picture>
</div> </div>
<!-- Content --> <!-- Content -->

View File

@@ -77,6 +77,20 @@ export default defineNuxtConfig({
], ],
link: [ link: [
{ rel: 'canonical', href: 'https://www.harheimertc.de/' }, { rel: 'canonical', href: 'https://www.harheimertc.de/' },
{
rel: 'preload',
as: 'image',
href: '/images/club_about_us_hero_960.webp',
type: 'image/webp',
media: '(max-width: 1024px)'
},
{
rel: 'preload',
as: 'image',
href: '/images/club_about_us_hero_1600.webp',
type: 'image/webp',
media: '(min-width: 1025px)'
},
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' }, { rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' }, { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
{ {

View File

@@ -226,17 +226,17 @@
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, defineAsyncComponent, 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'
import HomeTermine from '~/components/HomeTermine.vue' const HomeTermine = defineAsyncComponent(() => import('~/components/HomeTermine.vue'))
import Spielplan from '~/components/Spielplan.vue' const Spielplan = defineAsyncComponent(() => import('~/components/Spielplan.vue'))
import PublicNews from '~/components/PublicNews.vue' const PublicNews = defineAsyncComponent(() => import('~/components/PublicNews.vue'))
import HomeActions from '~/components/HomeActions.vue' const HomeActions = defineAsyncComponent(() => import('~/components/HomeActions.vue'))
import HomeTrainingTeaser from '~/components/HomeTrainingTeaser.vue' const HomeTrainingTeaser = defineAsyncComponent(() => import('~/components/HomeTrainingTeaser.vue'))
import HomeLinksTeaser from '~/components/HomeLinksTeaser.vue' const HomeLinksTeaser = defineAsyncComponent(() => import('~/components/HomeLinksTeaser.vue'))
import HomeVereinsmeisterschaftenTeaser from '~/components/HomeVereinsmeisterschaftenTeaser.vue' const HomeVereinsmeisterschaftenTeaser = defineAsyncComponent(() => import('~/components/HomeVereinsmeisterschaftenTeaser.vue'))
import HomeSpielplanTeamWidget from '~/components/HomeSpielplanTeamWidget.vue' const HomeSpielplanTeamWidget = defineAsyncComponent(() => 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')

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,30 @@
function normalizeBaseUrl(value) {
const raw = String(value || '').trim()
if (!raw) return ''
return raw.replace(/\/+$/, '')
}
export default defineEventHandler((event) => {
const runtimeConfig = useRuntimeConfig(event)
const requestUrl = getRequestURL(event)
const baseUrl = normalizeBaseUrl(runtimeConfig.public?.baseUrl) || `${requestUrl.protocol}//${requestUrl.host}`
const lines = [
'User-agent: *',
'Allow: /',
'Disallow: /cms',
'Disallow: /cms/',
'Disallow: /mitgliederbereich',
'Disallow: /mitgliederbereich/',
'Disallow: /api/',
'Disallow: /login',
'Disallow: /registrieren',
'Disallow: /passwort-vergessen',
'Disallow: /konto-loeschen',
`Sitemap: ${baseUrl}/sitemap.xml`
]
setHeader(event, 'Content-Type', 'text/plain; charset=utf-8')
return `${lines.join('\n')}\n`
})

View File

@@ -0,0 +1,87 @@
function normalizeBaseUrl(value) {
const raw = String(value || '').trim()
if (!raw) return ''
return raw.replace(/\/+$/, '')
}
function escapeXml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
function toAbsoluteUrl(baseUrl, path) {
const normalizedPath = path.startsWith('/') ? path : `/${path}`
return `${baseUrl}${normalizedPath}`
}
export default defineEventHandler((event) => {
const runtimeConfig = useRuntimeConfig(event)
const requestUrl = getRequestURL(event)
const baseUrl = normalizeBaseUrl(runtimeConfig.public?.baseUrl) || `${requestUrl.protocol}//${requestUrl.host}`
const today = new Date().toISOString().slice(0, 10)
const routes = [
'/',
'/kontakt',
'/termine',
'/mitgliedschaft',
'/spielplan',
'/mannschaften',
'/mannschaften/spielplaene',
'/mannschaften/herren',
'/mannschaften/damen',
'/mannschaften/jugend',
'/training',
'/training/trainer',
'/training/anfaenger',
'/vereinsmeisterschaften',
'/spielsysteme',
'/links',
'/vorstand',
'/impressum',
'/datenschutz',
'/tt-regeln',
'/ueber-uns',
'/geschichte',
'/satzung',
'/galerie',
'/verein/ueber-uns',
'/verein/geschichte',
'/verein/satzung',
'/verein/tt-regeln',
'/verein/galerie',
'/newsletter/subscribe',
'/newsletter/unsubscribe'
]
const uniqueRoutes = [...new Set(routes)]
const entries = uniqueRoutes.map((route) => {
const loc = escapeXml(toAbsoluteUrl(baseUrl, route))
const priority = route === '/' ? '1.0' : '0.7'
return [
' <url>',
` <loc>${loc}</loc>`,
` <lastmod>${today}</lastmod>`,
' <changefreq>weekly</changefreq>',
` <priority>${priority}</priority>`,
' </url>'
].join('\n')
})
const xml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
entries.join('\n'),
'</urlset>',
''
].join('\n')
setHeader(event, 'Content-Type', 'application/xml; charset=utf-8')
return xml
})