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
import androidx.compose.foundation.Image
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -72,32 +74,62 @@ private fun CompactNavigation(
onNavigate: (String) -> Unit,
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)
var cmsExpanded = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
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) })
Box(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
modifier = Modifier.horizontalScroll(mainScroll),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate)
CompactLink("Verein", Destinations.VereinAbout.route, selectedRoute, onNavigate)
CompactLink("Mannschaften", Destinations.Mannschaften.route, selectedRoute, onNavigate)
CompactLink("Training", Destinations.Training.route, selectedRoute, onNavigate)
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate)
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate)
CompactLink("Newsletter", Destinations.NewsletterSubscribe.route, selectedRoute, onNavigate)
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate) { sectionOverride.value = null }
CompactSectionLink("Verein", MenuSection.VEREIN, section) { sectionOverride.value = MenuSection.VEREIN }
CompactSectionLink("Mannschaften", MenuSection.MANNSCHAFTEN, section) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
CompactSectionLink("Training", MenuSection.TRAINING, section) { sectionOverride.value = MenuSection.TRAINING }
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate) { sectionOverride.value = null }
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
CompactSectionLink("Newsletter", MenuSection.NEWSLETTER, section) { sectionOverride.value = MenuSection.NEWSLETTER }
if (navigationState.showGallery) {
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate)
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.VEREIN }
}
if (navigationState.loggedIn) {
CompactLink("Intern", Destinations.MemberArea.route, selectedRoute, onNavigate)
CompactSectionLink("Intern", MenuSection.INTERN, section) { sectionOverride.value = MenuSection.INTERN }
}
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate)
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate) { sectionOverride.value = null }
if (navigationState.isAdmin || navigationState.canAccessNewsletter || navigationState.canAccessContactRequests) {
CompactLink("CMS", Destinations.Cms.route, selectedRoute, onNavigate)
CompactSectionLink("CMS", MenuSection.INTERN, section) { sectionOverride.value = MenuSection.INTERN }
}
}
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),
)
}
if (mainScroll.canScrollForward) {
Text(
"",
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(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.horizontalScroll(subScroll)
.padding(top = 3.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
@@ -126,12 +158,15 @@ private fun CompactNavigation(
}
}
}
if (subItems.isNotEmpty()) {
ScrollHintRow(subScroll)
}
if (cmsExpanded.value && cmsChildren.isNotEmpty()) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.horizontalScroll(cmsSubScroll)
.padding(top = 6.dp, bottom = 3.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
@@ -139,6 +174,31 @@ private fun CompactNavigation(
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?,
onNavigate: (String) -> Unit,
modifier: Modifier = Modifier,
beforeNavigate: () -> Unit = {},
) {
Surface(
color = if (route == selectedRoute) Primary600 else Color.Transparent,
shape = RoundedCornerShape(8.dp),
modifier = modifier.clickable { onNavigate(route) },
modifier = modifier.clickable {
beforeNavigate()
onNavigate(route)
},
) {
Text(
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
private fun SubLink(label: String, selected: Boolean, onClick: () -> Unit) {
Surface(

View File

@@ -5,11 +5,11 @@ org.gradle.workers.max=2
LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/
# 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_VERSION_CODE=17
ANDROID_VERSION_NAME=0.9.12
ANDROID_VERSION_CODE=18
ANDROID_VERSION_NAME=0.9.13
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
RELEASE_MINIFY_ENABLED=false

View File

@@ -7,11 +7,22 @@
<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" />
<!-- Hintergrundbild -->
<div
class="absolute inset-0 opacity-10"
style="background-image: url('/images/club_about_us.png'); background-size: cover; background-position: center;"
/>
<picture class="absolute inset-0 opacity-10">
<source
type="image/webp"
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>
<!-- Content -->

View File

@@ -77,6 +77,20 @@ export default defineNuxtConfig({
],
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: '' },
{

View File

@@ -226,17 +226,17 @@
</template>
<script setup>
import { computed, ref } from 'vue'
import { computed, defineAsyncComponent, ref } from 'vue'
import { SlidersHorizontal, X } from 'lucide-vue-next'
import Hero from '~/components/Hero.vue'
import HomeTermine from '~/components/HomeTermine.vue'
import Spielplan from '~/components/Spielplan.vue'
import PublicNews from '~/components/PublicNews.vue'
import HomeActions from '~/components/HomeActions.vue'
import HomeTrainingTeaser from '~/components/HomeTrainingTeaser.vue'
import HomeLinksTeaser from '~/components/HomeLinksTeaser.vue'
import HomeVereinsmeisterschaftenTeaser from '~/components/HomeVereinsmeisterschaftenTeaser.vue'
import HomeSpielplanTeamWidget from '~/components/HomeSpielplanTeamWidget.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 { data: config } = await useFetch('/api/config')
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
})